Compare commits

...

62 Commits
v0.0.2 ... main

Author SHA1 Message Date
0d184e27e8
Update workflow Go version 2025-03-21 22:56:35 +00:00
c737bab781
Update dependencies 2025-03-21 22:46:17 +00:00
dependabot[bot]
9e0ba09a84
Bump github.com/golang-jwt/jwt/v4 from 4.5.1 to 4.5.2
Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.1 to 4.5.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.1...v4.5.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 22:44:10 +00:00
dependabot[bot]
4bbeaa87e1
Bump golang.org/x/net from 0.35.0 to 0.36.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-19 21:16:16 +00:00
dependabot[bot]
35bc5ae4ab
Bump github.com/go-jose/go-jose/v4 from 4.0.4 to 4.0.5
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.0.4 to 4.0.5.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.0.4...v4.0.5)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-25 13:48:37 +00:00
02bfb4f62f
Update dependencies 2025-02-19 21:17:14 +00:00
56be812ff5
Only update the agent last sync when all copies were successful 2025-02-18 21:40:56 +00:00
8407f21090
Fix broken agent tests 2025-02-18 21:33:15 +00:00
fc2f8e34c6
Refactor agent to copy all files for an agent dir at the same time 2025-02-18 21:19:02 +00:00
a23155a827
Does this resolve issues with fullFilePath 2025-02-01 00:59:28 +00:00
432c907303
Add HostKeyAlgorithms field 2025-02-01 00:51:40 +00:00
b642957aaf
Only accept ed25519 kex 2025-01-31 23:23:30 +00:00
99c4c38bd5
Panic when this error occurs in testing 2025-01-31 23:23:14 +00:00
c4c8c33139
Add initial agent check when starting 2025-01-31 23:18:29 +00:00
939875ca4c
Add agent for certificate syncing 2025-01-31 20:22:29 +00:00
7e70331179
Add utils/name.go for cert and key file names 2025-01-31 19:05:49 +00:00
645d22b856
Move trySetup to setup.go 2025-01-31 19:05:49 +00:00
c373f18336
Change certificates.not_after and certificates.renew_retry to allow null values 2025-01-31 19:05:48 +00:00
c247a50472
Leave a comment suggesting to use a custom logger for lego 2025-01-31 19:05:48 +00:00
4105d14e63
Some further clean up of main and setup 2025-01-31 19:05:48 +00:00
bb7c4bcedc
Simplify serving and setup in main binary 2025-01-31 19:05:44 +00:00
0722b67969
Update to Go 1.23.5 and update dependencies 2025-01-29 18:29:37 +00:00
619f909767
Update dependencies 2025-01-26 22:42:12 +00:00
1f1db49160
Update Go to 1.23 and update dependencies 2024-12-24 15:28:05 +00:00
dependabot[bot]
f806c7d230
Bump golang.org/x/crypto from 0.26.0 to 0.31.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.26.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.26.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-12 18:39:38 +00:00
dependabot[bot]
7768a339c8
Bump github.com/golang-jwt/jwt/v4 from 4.5.0 to 4.5.1
Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.0...v4.5.1)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-05 17:53:22 +00:00
7f24d37e31
Disable stopping on first failure 2024-09-21 18:31:07 +01:00
d0fc76cd73
Retry certificate renewal after failure 2024-09-21 18:31:02 +01:00
7e65015b89
Update logging 2024-09-21 16:57:15 +01:00
37964ad546
Add more debug logging 2024-09-21 14:31:23 +01:00
76baa5f33f
Update cert and key path 2024-08-13 00:01:52 +01:00
2839abbf52
Update dependencies 2024-08-12 22:27:23 +01:00
25e1065f05
Output slice from owned endpoint 2024-06-23 12:48:27 +01:00
55bfc13457
Parallel testing is cool 2024-06-09 00:09:07 +01:00
fc837761cc
Use formatted date instead of integer in test queries 2024-06-06 12:24:59 +01:00
01f15e27a3
Update logger for running for API server 2024-06-04 20:07:20 +01:00
e92207172e
Update dependencies 2024-06-04 20:02:49 +01:00
5532aa9782
Fix broken logging 2024-06-04 18:26:07 +01:00
dee4b7ee3a
Transition to new logger 2024-05-13 19:04:43 +01:00
fa92da5f1d
Fix test destruction removing tmp folders 2024-05-13 18:51:41 +01:00
b7f3dc54c7
Update dependencies 2024-05-13 18:40:57 +01:00
7ff5ec4d57
Remove unused parallel option 2024-05-13 18:33:45 +01:00
4e959ecdfb
Update go version 2024-05-13 18:33:30 +01:00
2b160d4309
Continue changes for sqlc 2024-05-13 18:33:23 +01:00
094ac9030a
Start updating to support sqlc and migrations 2024-05-13 18:33:06 +01:00
dependabot[bot]
9b3c801ebf
Bump github.com/go-jose/go-jose/v3 from 3.0.1 to 3.0.3
Bumps [github.com/go-jose/go-jose/v3](https://github.com/go-jose/go-jose) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/v3.0.3/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v3.0.1...v3.0.3)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-09 00:56:28 +00:00
b10db2f73d
Better error checking for already renewing 2024-01-14 13:59:36 +00:00
dependabot[bot]
8234a34c6f
Bump golang.org/x/crypto from 0.16.0 to 0.17.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-02 14:50:02 +00:00
445e2bf30d
Update test workflow go version 2024-01-02 14:50:01 +00:00
0c467542a0
Update dependencies 2024-01-02 14:50:01 +00:00
dd77f3836e
Use domains in certificate json 2024-01-02 14:50:00 +00:00
c08e73ecfc
Use continue after moving certificate to main map 2024-01-02 14:49:59 +00:00
82704e5a13
Add endpoint for grabbing owned certificates 2024-01-02 14:49:59 +00:00
dependabot[bot]
37cc96fba1
Bump golang.org/x/net from 0.12.0 to 0.17.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.12.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.12.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-02 14:49:58 +00:00
36aca8af0b
Add API endpoint response 2023-10-17 00:11:15 +01:00
a744f450f7
Replace exit and reload logic with my new library 2023-07-24 15:53:32 +01:00
bfdcb07613
Use insecure directory certificate for tests 2023-07-22 01:41:56 +01:00
d300424706
Rename module 2023-07-22 01:39:39 +01:00
fa3f4ad99f
Add logging for dns provider 2023-07-16 16:58:29 +01:00
cfb2fd6fcb
I have no idea why this doesn't work 2023-07-16 16:37:07 +01:00
572325f7bf
Use update registration instead 2023-07-16 16:19:06 +01:00
f680332340
Increase propagation and polling timeout for dns changes 2023-07-16 13:38:01 +01:00
54 changed files with 2191 additions and 625 deletions

View File

@ -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
View File

@ -1,3 +1,4 @@
*.sqlite
*.local
.data/
.idea/

8
.idea/.gitignore generated vendored
View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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
View 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
View 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
View 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
View 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
View 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
}

View File

@ -1,12 +1,13 @@
package main
import "github.com/MrMelon54/orchid/renewal"
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 {

View File

@ -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
}

View File

@ -1,113 +0,0 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"github.com/MrMelon54/mjwt"
httpAcme "github.com/MrMelon54/orchid/http-acme"
"github.com/MrMelon54/orchid/renewal"
"github.com/MrMelon54/orchid/servers"
"github.com/MrMelon54/violet/utils"
"github.com/google/subcommands"
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v3"
"log"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
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)
// Wait for exit signal
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-sc
fmt.Println()
// Stop servers
log.Printf("[Orchid] Stopping...")
n := time.Now()
// stop renewal service and api server
renewalService.Shutdown()
srv.Close()
log.Printf("[Orchid] Took '%s' to shutdown\n", time.Now().Sub(n))
log.Println("[Orchid] Goodbye")
}

View File

@ -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"
httpAcme "github.com/MrMelon54/orchid/http-acme"
"github.com/MrMelon54/orchid/renewal"
"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
View 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
View 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
}

View 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
View 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,
}
}

View 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
);

View File

@ -0,0 +1,5 @@
ALTER TABLE certificates
DROP COLUMN renew_failed;
ALTER TABLE certificates
ADD COLUMN renew_retry DATETIME NOT NULL DEFAULT 0;

View 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)
);

View 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
View 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"`
}

View 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 = ?;

View 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 = ?;

View 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
View 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()
}

76
go.mod
View File

@ -1,41 +1,63 @@
module github.com/MrMelon54/orchid
module github.com/1f349/orchid
go 1.20
go 1.24.1
require (
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/mjwt v0.1.1
github.com/MrMelon54/violet v0.0.2
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
gopkg.in/yaml.v3 v3.0.1 // 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
)

159
go.sum
View File

@ -1,119 +1,168 @@
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/mjwt v0.1.1 h1:m+aTpxbhQCrOPKHN170DQMFR5r938LkviU38unob5Jw=
github.com/MrMelon54/mjwt v0.1.1/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk=
github.com/MrMelon54/violet v0.0.2 h1:eKyeNDncF62PFE+wi+UN2qGgzFrL+7llVo3roPb9Lkw=
github.com/MrMelon54/violet v0.0.2/go.mod h1:lkxrHUtX9llm8wKCHvH9SQHilkhII/MIIbCy9U4jEHU=
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=

View File

@ -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)
}
}

View File

@ -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
View 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
View 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",
})

View File

@ -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
);

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,5 @@
package renewal
import "github.com/1f349/orchid/logger"
var Logger = logger.Logger.WithPrefix("Orchid Renewal")

View File

@ -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/MrMelon54/orchid/pebble"
"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{}
@ -69,11 +64,12 @@ type Service struct {
certDir string
keyDir string
insecure bool
client *lego.Client
}
// 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) {
r := &Service{
func NewService(wg *sync.WaitGroup, db *database.Queries, httpAcme challenge.Provider, leConfig LetsEncryptConfig, certDir, keyDir string) (*Service, error) {
s := &Service{
db: db,
httpAcme: httpAcme,
certTicker: time.NewTicker(time.Minute * 10),
@ -98,33 +94,34 @@ func NewService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provider, leC
}
// load lets encrypt private key
err = r.resolveLEPrivKey(leConfig.Account.PrivateKey)
err = s.resolveLEPrivKey(leConfig.Account.PrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to resolve LetsEncrypt account private key: %w", err)
}
// init domains table
_, err = r.db.Exec(createTableCertificates)
if err != nil {
return nil, fmt.Errorf("failed to create certificates table: %w", err)
}
// resolve CA information
r.resolveCADirectory(leConfig.Directory)
err = r.resolveCACertificate(leConfig.Certificate)
s.resolveCADirectory(leConfig.Directory)
err = s.resolveCACertificate(leConfig.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to resolve CA certificate: %w", err)
}
// setup client for requesting a new certificate
client, err := s.setupLegoClient()
if err != nil {
return nil, fmt.Errorf("failed to generate a client: %w", err)
}
s.client = client
// start the background routine
wg.Add(1)
go r.renewalRoutine(wg)
return r, nil
go s.renewalRoutine(wg)
return s, nil
}
// 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)
}
@ -205,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 {
@ -230,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)
}
}()
}
@ -250,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 {
@ -258,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
}
@ -277,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)
@ -328,7 +313,7 @@ func (s *Service) fetchDomains(localData *localCertData) ([]string, error) {
return domains, nil
}
func (s *Service) setupLegoClient(localData *localCertData) (*lego.Client, error) {
func (s *Service) setupLegoClient() (*lego.Client, error) {
// create lego config and change the certificate authority directory URL and the
// http.Client transport if an alternative is provided
config := lego.NewConfig(s.leAccount)
@ -338,6 +323,7 @@ func (s *Service) setupLegoClient(localData *localCertData) (*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)
@ -346,23 +332,6 @@ func (s *Service) setupLegoClient(localData *localCertData) (*lego.Client, error
// set http challenge provider
_ = client.Challenge.SetHTTP01Provider(s.httpAcme)
// if testDnsOptions is defined then set up the test provider
if testDnsOptions != nil {
// 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)
_ = 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
// set up the specific dns provider requested
dnsProv, err := s.getDnsProvider(localData.dns.name.String, localData.dns.token.String)
if err != nil {
return nil, fmt.Errorf("failed to resolve dns provider: %w", err)
}
_ = client.Challenge.SetDNS01Provider(dnsProv)
}
// make sure the LetsEncrypt account is registered
register, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
@ -377,15 +346,21 @@ func (s *Service) setupLegoClient(localData *localCertData) (*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) {
Logger.Info("Loading dns provider", "name", name, "token", token[:3]+"*****")
switch name {
case "duckdns":
config := duckdns.NewDefaultConfig()
config.Token = token
return duckdns.NewDNSProviderConfig(config)
return duckdns.NewDNSProviderConfig(&duckdns.Config{
Token: token,
PropagationTimeout: 15 * time.Minute,
PollingInterval: 2 * time.Minute,
})
case "namesilo":
config := namesilo.NewDefaultConfig()
config.APIKey = token
return namesilo.NewDNSProviderConfig(config)
return namesilo.NewDNSProviderConfig(&namesilo.Config{
APIKey: token,
PropagationTimeout: 2 * time.Hour,
PollingInterval: 15 * time.Minute,
TTL: 3600,
})
default:
return nil, ErrUnsupportedDNSProvider
}
@ -393,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) {
@ -425,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)
}
@ -471,14 +455,28 @@ func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate
return nil, nil, fmt.Errorf("failed to update cert: %w", err)
}
// setup client for requesting a new certificate
client, err := s.setupLegoClient(localData)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate a client: %w", err)
// remove old dns challenge
s.client.Challenge.Remove(challenge.DNS01)
// if testDnsOptions is defined then set up the test provider
if testDnsOptions != nil {
// set up the dns provider used during tests and disable propagation as no dns
// will validate these tests
dnsAddr := testDnsOptions.GetDnsAddrs()
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
// set up the specific dns provider requested
dnsProv, err := s.getDnsProvider(localData.dns.name.String, localData.dns.token.String)
if err != nil {
return nil, nil, fmt.Errorf("failed to resolve dns provider: %w", err)
}
_ = s.client.Challenge.SetDNS01Provider(dnsProv)
}
// obtain new certificate - this call will hang until a certificate is ready
obtain, err := client.Certificate.Obtain(certificate.ObtainRequest{
obtain, err := s.client.Certificate.Obtain(certificate.ObtainRequest{
Domains: domains,
PrivateKey: privKey,
Bundle: true,
@ -504,19 +502,29 @@ func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate
}
// setRenewing sets the renewing and failed states in the database for a
// specified certifcate 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)
// specified certificate 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)
@ -540,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
}

View File

@ -9,19 +9,19 @@ import (
"database/sql"
"encoding/pem"
"fmt"
"github.com/MrMelon54/certgen"
"github.com/MrMelon54/orchid/pebble"
"github.com/MrMelon54/orchid/test"
"github.com/1f349/orchid"
"github.com/1f349/orchid/logger"
"github.com/1f349/orchid/pebble"
"github.com/1f349/orchid/test"
"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"
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
@ -54,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))
@ -73,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
@ -84,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")
@ -96,24 +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)
db2, err := sql.Open("sqlite3", dbFile)
assert.NoError(t, err)
log.Println("DB File:", dbFile)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig.InsecureSkipVerify = true
req, err := http.NewRequest(http.MethodGet, "https://localhost:14000/root", nil)
assert.NoError(t, err)
res, err := tr.RoundTrip(req)
assert.NoError(t, err)
certRaw, err := io.ReadAll(res.Body)
assert.NoError(t, err)
Logger.Info("DB File:", "db", dbFile)
certDir, err := os.MkdirTemp("", "orchid-certs")
keyDir, err := os.MkdirTemp("", "orchid-keys")
@ -128,20 +123,27 @@ func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) *Service {
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(lePrivKey)})),
},
Directory: "https://localhost:14000/dir",
Certificate: string(certRaw),
Certificate: "insecure",
insecure: true,
}, certDir, keyDir)
fmt.Println(err)
assert.NoError(t, err)
service.transport = acmeProv
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
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")
}
@ -161,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

View File

@ -1,15 +1,20 @@
package servers
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"fmt"
"github.com/MrMelon54/mjwt"
"github.com/MrMelon54/mjwt/claims"
oUtils "github.com/MrMelon54/orchid/utils"
vUtils "github.com/MrMelon54/violet/utils"
"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/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
}
}

View File

@ -1,12 +1,12 @@
package servers
import (
"database/sql"
"github.com/MrMelon54/mjwt"
"github.com/MrMelon54/mjwt/auth"
vUtils "github.com/MrMelon54/violet/utils"
"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/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
}

View File

@ -1,48 +1,37 @@
package servers
import (
"database/sql"
"context"
"encoding/json"
"fmt"
"github.com/MrMelon54/mjwt"
"github.com/MrMelon54/orchid/renewal"
"github.com/MrMelon54/orchid/utils"
"github.com/1f349/mjwt"
"github.com/1f349/orchid/database"
"github.com/1f349/orchid/renewal"
"github.com/1f349/orchid/utils"
"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
View 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

View File

@ -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)
}
}

View File

@ -1,6 +1,6 @@
package utils
import "github.com/MrMelon54/violet/utils"
import "github.com/1f349/violet/utils"
type DomainChecker []string

15
utils/name.go Normal file
View 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)
}