Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ on:
- '**'
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@v5
Expand All @@ -30,7 +35,7 @@ jobs:
- name: Lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.1.6
version: v2.3.0

- name: Vendorcheck
shell: bash
Expand Down
9 changes: 9 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@ version: "2"
linters:
default: all
disable:
- err113
- depguard
- lll
- musttag
- wsl
- wsl_v5
settings:
misspell:
locale: US
funlen:
lines: -1
gosec:
excludes:
- G104 # Errors unhandled
- G304 # Potential file inclusion via variable
varnamelen:
ignore-names:
- id
- tc
exclusions:
presets:
- std-error-handling
Expand Down
52 changes: 52 additions & 0 deletions cmd/sunlight-secretmanager/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"fmt"
"os"

"gopkg.in/yaml.v3"
)

// config is a subset of Sunlight's Config. We use it to load config information
// about each log.
type config struct {
Logs []logConfig
}

// logConfig is a subset of Sunlight's LogConfig. We use it to load just the
// info we need about each individual log.
type logConfig struct {
// Name is the unique human-readable identifier of the log. We use it for
// logging purposes.
Name string
// Secret is the path to the file where the sunlight instance expects to
// find this log's secret seed. We write the seed to this path.
Secret string
}

// loadConfig takes path to a yaml file and returns the seeds in that log file.
// Exported for use in main.go.
func loadConfig(configFile string) (*config, error) {
yml, err := os.ReadFile(configFile)
if err != nil {
return nil, fmt.Errorf("reading config file %q: %w", configFile, err)
}

var sunlightConfig config
err = yaml.Unmarshal(yml, &sunlightConfig)
if err != nil {
return nil, fmt.Errorf("parsing config file %q: %w", configFile, err)
}

if len(sunlightConfig.Logs) == 0 {
return nil, fmt.Errorf("no logs found in config file %q", configFile)
}

for _, log := range sunlightConfig.Logs {
if log.Name == "" || log.Secret == "" {
return nil, fmt.Errorf("incomplete config for log %q in config file %q", log.Name, configFile)
}
}

return &sunlightConfig, nil
}
87 changes: 87 additions & 0 deletions cmd/sunlight-secretmanager/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"path/filepath"
"reflect"
"strings"
"testing"
)

func TestLoadConfig(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
input string
want *config
wantErr string
}{
{
name: "invalid path",
input: "",
want: nil,
wantErr: "reading config file",
},
{
name: "invalid yaml",
input: "invalid.yaml",
want: nil,
wantErr: "parsing config file",
},
{
name: "empty",
input: "empty.yaml",
want: nil,
wantErr: "no logs found in config file", // an empty file is valid yaml
},
{
name: "no logs",
input: "nologs.yaml",
want: nil,
wantErr: "no logs found in config file",
},
{
name: "incomplete",
input: "missingkeys.yaml",
want: nil,
wantErr: "incomplete config for log",
},
{
name: "happy path",
input: "happy.yaml",
want: &config{
Logs: []logConfig{
{
Name: "test.tld/shard1",
Secret: "/path/to/shard1.seed",
},
{
Name: "test.tld/shard2",
Secret: "/path/to/shard2.seed",
},
},
},
wantErr: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

got, err := loadConfig(filepath.Join("testdata", tc.input))

if tc.wantErr != "" { //nolint:nestif
if err == nil {
t.Errorf("loadConfig() = %#v, but want %q", got, tc.wantErr)
} else if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("loadConfig() = %q, but want %q", err, tc.wantErr)
}
} else {
if err != nil {
t.Errorf("loadConfig() = %q, but want %#v", err, tc.want)
} else if !reflect.DeepEqual(got, tc.want) {
t.Errorf("loadConfig() = %#v, but want %#v", got, tc.want)
}
}
})
}
}
47 changes: 47 additions & 0 deletions cmd/sunlight-secretmanager/filesystem.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"fmt"
"os"
"syscall"
)

func writeFile(path string, content []byte, fsType int64) error {
// We currently assume that the directory we are trying to write to already exists.
file, err := os.OpenFile(
path,
// The combination of O_CREATE and O_EXCL means this operation will fail
// if the file already exists.
os.O_RDWR|os.O_CREATE|os.O_EXCL,
// Setting nolint here because file permissions octal value isn't a magic number.
//nolint: mnd
0o400,
)
if err != nil {
return fmt.Errorf("creating file at path %q: %w", path, err)
}
defer file.Close()

var statfs syscall.Statfs_t
err = syscall.Fstatfs(int(file.Fd()), &statfs)
if err != nil {
_ = os.Remove(file.Name())

return fmt.Errorf("getting filesystem info at path %q: %w", path, err)
}

if statfs.Type != fsType {
_ = os.Remove(file.Name())

return fmt.Errorf("filesystem at path %q has type %v, but we require %v", path, statfs.Type, fsType)
}

_, err = file.Write(content)
if err != nil {
_ = os.Remove(file.Name())

return fmt.Errorf("writing to file at path %q: %w", path, err)
}

return nil
}
76 changes: 76 additions & 0 deletions cmd/sunlight-secretmanager/filesystem_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestWriteFile(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()

// Do some setup for the "already exists" test.
f, err := os.Create(filepath.Join(tempDir, "already-exists"))
if err != nil {
t.Fatalf("failed to create test setup file: %s", err)
}
err = f.Close()
if err != nil {
t.Fatalf("failed to close test setup file: %s", err)
}

for _, tc := range []struct {
name string
path string
fsType int64
wantErr string
}{
{
name: "already exists",
path: filepath.Join(tempDir, "already-exists"),
fsType: 1,
wantErr: "creating file at path",
},
{
name: "wrong fstype",
path: filepath.Join(tempDir, "wrong-fstype"),
fsType: 1,
wantErr: "filesystem at path",
},
{
name: "happy path",
path: filepath.Join(tempDir, "happy"),
fsType: 61267, // The statfs.Type for a normal unix filesystem
wantErr: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

err := writeFile(tc.path, []byte("hello world"), tc.fsType)

if tc.wantErr != "" { //nolint:nestif
if err == nil {
t.Errorf("writeFile() = succeeded, but want error %q", tc.wantErr)
} else if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("writeFile() = %#v, but want %q", err, tc.wantErr)
}
} else {
if err != nil {
t.Fatalf("writeFile() = %#v, but want success", err)
}

got, err := os.ReadFile(tc.path)
if err != nil {
t.Fatalf("failed to re-read file: %s", err)
}

if string(got) != "hello world" {
t.Errorf("written file contains %q, but want %q", string(got), "hello world")
}
}
})
}
}
52 changes: 29 additions & 23 deletions cmd/sunlight-secretmanager/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Sunlight-secretmanager creates new Sunlight CT Log instances, ensuring that
// their signing keys are stored in an appropriate location.
// sunlight-secretmanager ensures that Sunlight CT Log instances have access to
// their secret key material. In particular, it reads the log's config file,
// extracts the paths at which each log in that config expects to find its
// key material, fetches the corresponding secrets from the AWS Secrets Manager
// API, and writes the secrets to the expected location.
//
// Usage:
//
Expand All @@ -13,45 +16,48 @@ import (
"os"

awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/letsencrypt/sunlight-secretmanager/config"
secrets "github.com/letsencrypt/sunlight-secretmanager/secrets"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

// Documentation for linuxTmpfsConst: https://elixir.bootlin.com/linux/v4.6/source/include/uapi/linux/magic.h#L16
const linuxTmpfsConst int64 = 0x01021994
// tmpfsMagic is the magic number used to indicate that a unix filesystem is
// a tmpfs. The value is copied from golang.org/x/sys/unix.TMPFS_MAGIC, which we
// can't use here because its source file has a "go:build linux" directive.
// https://cs.opensource.google/go/x/sys/+/refs/tags/v0.34.0:unix/zerrors_linux.go;l=3498
const tmpfsMagic = 0x1021994

func main() {
flagset := flag.NewFlagSet("sunlight", flag.ExitOnError)
flagset := flag.NewFlagSet("sunlight-secretmanager", flag.ContinueOnError)
configFlag := flagset.String("config", "", "Path to YAML config file")
fileSystemFlag := flagset.Int64("filesystem", tmpfsMagic, "OS Filesystem constant to enforce writing to. Defaults to Linux tmpfs")

fileSystemFlag := flagset.Int64("filesystem", linuxTmpfsConst, "OS Filesystem constant to enforce writing to. Defaults to Linux tmpfs")

if err := flagset.Parse(os.Args[1:]); err != nil {
log.Fatalf("Error parsing flags: %v", err)
err := flagset.Parse(os.Args[1:])
if err != nil {
log.Fatalf("Error parsing flags: %s", err)
}

nameSeedMap, fileNamesMap, err := config.LoadConfigFromYaml(*configFlag)
config, err := loadConfig(*configFlag)
if err != nil {
log.Fatalf("failed to read or parse config file: [%v], err: [%v]", configFlag, err)
log.Fatalf("Error loading config: %s", err)
}

log.Printf("seeds: %v", nameSeedMap)

ctx := context.Background()

cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithSharedConfigProfile(os.Getenv("AWS_PROFILE")))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, the SDK checks the AWS_PROFILE environment variable to determine which profile to use

I know you're not touching this line, but I'm pretty sure this WithSharedConfigProfile line isn't needed

if err != nil {
log.Fatalf("unable to load AWS config: %v", err)
log.Fatalf("Error loading default AWS config: %s", err)
}

secret := secrets.New(cfg)
sm := secretsmanager.NewFromConfig(cfg)

returnedKeys, err := secret.FetchSecrets(ctx, nameSeedMap, fileNamesMap, secrets.Filesystem(*fileSystemFlag))
if err != nil {
log.Printf("failed to load AWS config: [%v], err: [%v]", configFlag, err)
}
for _, logConfig := range config.Logs {
seed, err := fetchSeed(ctx, sm, logConfig.Secret)
if err != nil {
log.Fatalf("Error fetching seed for %q: %v", logConfig.Name, err)
}

for key := range returnedKeys {
log.Printf("successfully loaded key %v", key)
err = writeFile(logConfig.Secret, seed, *fileSystemFlag)
if err != nil {
log.Fatalf("Error persisting seed for %q: %v", logConfig.Name, err)
}
}
}
Loading