Merge pull request #27 from ankeesler/issue-cert
Issue certs for valid TMC tokens
This commit is contained in:
commit
259fc0e794
@ -14,32 +14,27 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"k8s.io/apiserver/pkg/server/dynamiccertificates"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/util/intstr"
|
|
||||||
apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
|
|
||||||
|
|
||||||
"github.com/suzerain-io/placeholder-name/internal/autoregistration"
|
|
||||||
"github.com/suzerain-io/placeholder-name/internal/certauthority"
|
|
||||||
"github.com/suzerain-io/placeholder-name/internal/downward"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||||
|
"k8s.io/apiserver/pkg/server/dynamiccertificates"
|
||||||
genericoptions "k8s.io/apiserver/pkg/server/options"
|
genericoptions "k8s.io/apiserver/pkg/server/options"
|
||||||
"k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
|
"k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
|
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||||
restclient "k8s.io/client-go/rest"
|
restclient "k8s.io/client-go/rest"
|
||||||
|
apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
|
||||||
aggregationv1client "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
|
aggregationv1client "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
|
||||||
|
|
||||||
placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1"
|
placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1"
|
||||||
|
"github.com/suzerain-io/placeholder-name/internal/autoregistration"
|
||||||
|
"github.com/suzerain-io/placeholder-name/internal/certauthority"
|
||||||
|
"github.com/suzerain-io/placeholder-name/internal/downward"
|
||||||
"github.com/suzerain-io/placeholder-name/pkg/apiserver"
|
"github.com/suzerain-io/placeholder-name/pkg/apiserver"
|
||||||
"github.com/suzerain-io/placeholder-name/pkg/config"
|
"github.com/suzerain-io/placeholder-name/pkg/config"
|
||||||
)
|
)
|
||||||
@ -49,8 +44,10 @@ type App struct {
|
|||||||
cmd *cobra.Command
|
cmd *cobra.Command
|
||||||
|
|
||||||
// CLI flags
|
// CLI flags
|
||||||
configPath string
|
configPath string
|
||||||
downwardAPIPath string
|
downwardAPIPath string
|
||||||
|
clusterSigningCertFilePath string
|
||||||
|
clusterSigningKeyFilePath string
|
||||||
|
|
||||||
recommendedOptions *genericoptions.RecommendedOptions
|
recommendedOptions *genericoptions.RecommendedOptions
|
||||||
}
|
}
|
||||||
@ -121,6 +118,20 @@ authenticating to the Kubernetes API.`,
|
|||||||
"path to Downward API volume mount",
|
"path to Downward API volume mount",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&a.clusterSigningCertFilePath,
|
||||||
|
"cluster-signing-cert-file",
|
||||||
|
"",
|
||||||
|
"path to cluster signing certificate",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&a.clusterSigningKeyFilePath,
|
||||||
|
"cluster-signing-key-file",
|
||||||
|
"",
|
||||||
|
"path to cluster signing private key",
|
||||||
|
)
|
||||||
|
|
||||||
a.cmd = cmd
|
a.cmd = cmd
|
||||||
|
|
||||||
return a
|
return a
|
||||||
@ -140,9 +151,15 @@ func (a *App) run(
|
|||||||
return fmt.Errorf("could not load config: %w", err)
|
return fmt.Errorf("could not load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the Kubernetes cluster signing CA.
|
||||||
|
clientCA, err := certauthority.Load(a.clusterSigningCertFilePath, a.clusterSigningKeyFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not load cluster signing CA: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
webhookTokenAuthenticator, err := config.NewWebhook(cfg.WebhookConfig)
|
webhookTokenAuthenticator, err := config.NewWebhook(cfg.WebhookConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could create webhook client: %w", err)
|
return fmt.Errorf("could not create webhook client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
podinfo, err := downward.Load(a.downwardAPIPath)
|
podinfo, err := downward.Load(a.downwardAPIPath)
|
||||||
@ -152,19 +169,14 @@ func (a *App) run(
|
|||||||
|
|
||||||
// TODO use the postStart hook to generate certs?
|
// TODO use the postStart hook to generate certs?
|
||||||
|
|
||||||
ca, err := certauthority.New(pkix.Name{CommonName: "Placeholder CA"})
|
apiCA, err := certauthority.New(pkix.Name{CommonName: "Placeholder CA"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not initialize CA: %w", err)
|
return fmt.Errorf("could not initialize CA: %w", err)
|
||||||
}
|
}
|
||||||
caBundle, err := ca.Bundle()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not read CA bundle: %w", err)
|
|
||||||
}
|
|
||||||
log.Printf("initialized CA bundle:\n%s", string(caBundle))
|
|
||||||
|
|
||||||
const serviceName = "placeholder-name-api"
|
const serviceName = "placeholder-name-api"
|
||||||
|
|
||||||
cert, err := ca.Issue(
|
cert, err := apiCA.Issue(
|
||||||
pkix.Name{CommonName: serviceName + "." + podinfo.Namespace + ".svc"},
|
pkix.Name{CommonName: serviceName + "." + podinfo.Namespace + ".svc"},
|
||||||
[]string{},
|
[]string{},
|
||||||
24*365*time.Hour,
|
24*365*time.Hour,
|
||||||
@ -195,7 +207,7 @@ func (a *App) run(
|
|||||||
Spec: apiregistrationv1.APIServiceSpec{
|
Spec: apiregistrationv1.APIServiceSpec{
|
||||||
Group: placeholderv1alpha1.GroupName,
|
Group: placeholderv1alpha1.GroupName,
|
||||||
Version: placeholderv1alpha1.SchemeGroupVersion.Version,
|
Version: placeholderv1alpha1.SchemeGroupVersion.Version,
|
||||||
CABundle: caBundle,
|
CABundle: apiCA.Bundle(),
|
||||||
GroupPriorityMinimum: 2500, // TODO what is the right value? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#apiservicespec-v1beta1-apiregistration-k8s-io
|
GroupPriorityMinimum: 2500, // TODO what is the right value? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#apiservicespec-v1beta1-apiregistration-k8s-io
|
||||||
VersionPriority: 10, // TODO what is the right value? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#apiservicespec-v1beta1-apiregistration-k8s-io
|
VersionPriority: 10, // TODO what is the right value? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#apiservicespec-v1beta1-apiregistration-k8s-io
|
||||||
},
|
},
|
||||||
@ -210,7 +222,7 @@ func (a *App) run(
|
|||||||
return fmt.Errorf("could not register API service: %w", err)
|
return fmt.Errorf("could not register API service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiServerConfig, err := a.ConfigServer(cert, webhookTokenAuthenticator)
|
apiServerConfig, err := a.ConfigServer(cert, webhookTokenAuthenticator, clientCA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -223,7 +235,7 @@ func (a *App) run(
|
|||||||
return server.GenericAPIServer.PrepareRun().Run(ctx.Done())
|
return server.GenericAPIServer.PrepareRun().Run(ctx.Done())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ConfigServer(cert *tls.Certificate, webhookTokenAuthenticator *webhook.WebhookTokenAuthenticator) (*apiserver.Config, error) {
|
func (a *App) ConfigServer(cert *tls.Certificate, webhookTokenAuthenticator *webhook.WebhookTokenAuthenticator, ca *certauthority.CA) (*apiserver.Config, error) {
|
||||||
provider, err := createStaticCertKeyProvider(cert)
|
provider, err := createStaticCertKeyProvider(cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not create static cert key provider: %w", err)
|
return nil, fmt.Errorf("could not create static cert key provider: %w", err)
|
||||||
@ -239,6 +251,7 @@ func (a *App) ConfigServer(cert *tls.Certificate, webhookTokenAuthenticator *web
|
|||||||
GenericConfig: serverConfig,
|
GenericConfig: serverConfig,
|
||||||
ExtraConfig: apiserver.ExtraConfig{
|
ExtraConfig: apiserver.ExtraConfig{
|
||||||
Webhook: webhookTokenAuthenticator,
|
Webhook: webhookTokenAuthenticator,
|
||||||
|
Issuer: ca,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return apiServerConfig, nil
|
return apiServerConfig, nil
|
||||||
|
@ -25,10 +25,12 @@ Usage:
|
|||||||
placeholder-name [flags]
|
placeholder-name [flags]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-c, --config string path to configuration file (default "placeholder-name.yaml")
|
--cluster-signing-cert-file string path to cluster signing certificate
|
||||||
--downward-api-path string path to Downward API volume mount (default "/etc/podinfo")
|
--cluster-signing-key-file string path to cluster signing private key
|
||||||
-h, --help help for placeholder-name
|
-c, --config string path to configuration file (default "placeholder-name.yaml")
|
||||||
--log-flush-frequency duration Maximum number of seconds between log flushes (default 5s)
|
--downward-api-path string path to Downward API volume mount (default "/etc/podinfo")
|
||||||
|
-h, --help help for placeholder-name
|
||||||
|
--log-flush-frequency duration Maximum number of seconds between log flushes (default 5s)
|
||||||
`
|
`
|
||||||
|
|
||||||
func TestCommand(t *testing.T) {
|
func TestCommand(t *testing.T) {
|
||||||
|
@ -47,6 +47,8 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: #@ data.values.app_name
|
app: #@ data.values.app_name
|
||||||
|
annotations:
|
||||||
|
scheduler.alpha.kubernetes.io/critical-pod: ""
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: #@ data.values.app_name + "-service-account"
|
serviceAccountName: #@ data.values.app_name + "-service-account"
|
||||||
containers:
|
containers:
|
||||||
@ -62,11 +64,15 @@ spec:
|
|||||||
args:
|
args:
|
||||||
- --config=/etc/config/placeholder-name.yaml
|
- --config=/etc/config/placeholder-name.yaml
|
||||||
- --downward-api-path=/etc/podinfo
|
- --downward-api-path=/etc/podinfo
|
||||||
|
- --cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
|
||||||
|
- --cluster-signing-key-file=/etc/kubernetes/pki/ca.key
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config-volume
|
- name: config-volume
|
||||||
mountPath: /etc/config
|
mountPath: /etc/config
|
||||||
- name: podinfo
|
- name: podinfo
|
||||||
mountPath: /etc/podinfo
|
mountPath: /etc/podinfo
|
||||||
|
- name: k8s-certs
|
||||||
|
mountPath: /etc/kubernetes/pki
|
||||||
volumes:
|
volumes:
|
||||||
- name: config-volume
|
- name: config-volume
|
||||||
configMap:
|
configMap:
|
||||||
@ -80,3 +86,15 @@ spec:
|
|||||||
- path: "namespace"
|
- path: "namespace"
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.namespace
|
fieldPath: metadata.namespace
|
||||||
|
- name: k8s-certs
|
||||||
|
hostPath:
|
||||||
|
path: /etc/kubernetes/pki
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
priorityClassName: system-cluster-critical
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/master: ""
|
||||||
|
tolerations:
|
||||||
|
- key: CriticalAddonsOnly
|
||||||
|
operator: Exists
|
||||||
|
- effect: NoSchedule
|
||||||
|
key: node-role.kubernetes.io/master
|
||||||
|
1
go.mod
1
go.mod
@ -4,6 +4,7 @@ go 1.14
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1
|
github.com/davecgh/go-spew v1.1.1
|
||||||
|
github.com/golang/mock v1.4.3
|
||||||
github.com/golangci/golangci-lint v1.28.1
|
github.com/golangci/golangci-lint v1.28.1
|
||||||
github.com/google/go-cmp v0.4.0
|
github.com/google/go-cmp v0.4.0
|
||||||
github.com/spf13/cobra v1.0.0
|
github.com/spf13/cobra v1.0.0
|
||||||
|
8
go.sum
8
go.sum
@ -188,7 +188,10 @@ github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7 h1:5ZkaAPbicIKTF
|
|||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s=
|
||||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@ -674,6 +677,7 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8=
|
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8=
|
||||||
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
@ -853,6 +857,10 @@ mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b/go.mod h1:2odslEg/xrtNQqCYg2/jC
|
|||||||
mvdan.cc/unparam v0.0.0-20190720180237-d51796306d8f h1:Cq7MalBHYACRd6EesksG1Q8EoIAKOsiZviGKbOLIej4=
|
mvdan.cc/unparam v0.0.0-20190720180237-d51796306d8f h1:Cq7MalBHYACRd6EesksG1Q8EoIAKOsiZviGKbOLIej4=
|
||||||
mvdan.cc/unparam v0.0.0-20190720180237-d51796306d8f/go.mod h1:4G1h5nDURzA3bwVMZIVpwbkw+04kSxk3rAtzlimaUJw=
|
mvdan.cc/unparam v0.0.0-20190720180237-d51796306d8f/go.mod h1:4G1h5nDURzA3bwVMZIVpwbkw+04kSxk3rAtzlimaUJw=
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.9 h1:rusRLrDhjBp6aYtl9sGEvQJr6faoHoDLd0YcUBTZguI=
|
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.9 h1:rusRLrDhjBp6aYtl9sGEvQJr6faoHoDLd0YcUBTZguI=
|
||||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.9/go.mod h1:dzAXnQbTRyDlZPJX2SUPEqvnB+j7AJjtlox7PEwigU0=
|
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.9/go.mod h1:dzAXnQbTRyDlZPJX2SUPEqvnB+j7AJjtlox7PEwigU0=
|
||||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
|
sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
|
||||||
|
2
hack/header.txt
Normal file
2
hack/header.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Copyright 2020 VMware, Inc.
|
||||||
|
SPDX-License-Identifier: Apache-2.0
|
@ -22,8 +22,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CA holds the state for a simple x509 certificate authority suitable for use in an aggregated API service.
|
type env struct {
|
||||||
type CA struct {
|
|
||||||
// secure random number generators for various steps (usually crypto/rand.Reader, but broken out here for tests).
|
// secure random number generators for various steps (usually crypto/rand.Reader, but broken out here for tests).
|
||||||
serialRNG io.Reader
|
serialRNG io.Reader
|
||||||
keygenRNG io.Reader
|
keygenRNG io.Reader
|
||||||
@ -32,45 +31,73 @@ type CA struct {
|
|||||||
// clock tells the current time (usually time.Now(), but broken out here for tests).
|
// clock tells the current time (usually time.Now(), but broken out here for tests).
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
|
|
||||||
|
// parse function to parse an ASN.1 byte slice into an x509 struct (normally x509.ParseCertificate)
|
||||||
|
parseCert func([]byte) (*x509.Certificate, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CA holds the state for a simple x509 certificate authority suitable for use in an aggregated API service.
|
||||||
|
type CA struct {
|
||||||
|
// caCert is the DER-encoded certificate for the current CA.
|
||||||
|
caCertBytes []byte
|
||||||
|
|
||||||
// signer is the private key for the current CA.
|
// signer is the private key for the current CA.
|
||||||
signer crypto.Signer
|
signer crypto.Signer
|
||||||
|
|
||||||
// caCert is the DER-encoded certificate for the current CA.
|
// env is our reference to the outside world (clocks and random number generation).
|
||||||
caCertBytes []byte
|
env env
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option to pass when calling New.
|
// secureEnv is the "real" environment using secure RNGs and the real system clock.
|
||||||
type Option func(*CA) error
|
func secureEnv() env {
|
||||||
|
return env{
|
||||||
func New(subject pkix.Name, opts ...Option) (*CA, error) {
|
|
||||||
// Initialize the result by starting with some defaults and applying any provided options.
|
|
||||||
ca := CA{
|
|
||||||
serialRNG: rand.Reader,
|
serialRNG: rand.Reader,
|
||||||
keygenRNG: rand.Reader,
|
keygenRNG: rand.Reader,
|
||||||
signingRNG: rand.Reader,
|
signingRNG: rand.Reader,
|
||||||
clock: time.Now,
|
clock: time.Now,
|
||||||
|
parseCert: x509.ParseCertificate,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
}
|
||||||
if err := opt(&ca); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ErrInvalidCACertificate is returned when the contents of the loaded CA certificate do not meet our assumptions.
|
||||||
|
var ErrInvalidCACertificate = fmt.Errorf("invalid CA certificate")
|
||||||
|
|
||||||
|
// Load a certificate authority from an existing certificate and private key (in PEM format).
|
||||||
|
func Load(certPath string, keyPath string) (*CA, error) {
|
||||||
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not load CA: %w", err)
|
||||||
|
}
|
||||||
|
if certCount := len(cert.Certificate); certCount != 1 {
|
||||||
|
return nil, fmt.Errorf("%w: expected a single certificate, found %d certificates", ErrInvalidCACertificate, certCount)
|
||||||
|
}
|
||||||
|
return &CA{
|
||||||
|
caCertBytes: cert.Certificate[0],
|
||||||
|
signer: cert.PrivateKey.(crypto.Signer),
|
||||||
|
env: secureEnv(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New generates a fresh certificate authority with the given subject.
|
||||||
|
func New(subject pkix.Name) (*CA, error) { return newInternal(subject, secureEnv()) }
|
||||||
|
|
||||||
|
// newInternal is the internal guts of New, broken out for easier testing.
|
||||||
|
func newInternal(subject pkix.Name, env env) (*CA, error) {
|
||||||
|
ca := CA{env: env}
|
||||||
// Generate a random serial for the CA
|
// Generate a random serial for the CA
|
||||||
serialNumber, err := randomSerial(ca.serialRNG)
|
serialNumber, err := randomSerial(env.serialRNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not generate CA serial: %w", err)
|
return nil, fmt.Errorf("could not generate CA serial: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new P256 keypair.
|
// Generate a new P256 keypair.
|
||||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), ca.keygenRNG)
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), env.keygenRNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not generate CA private key: %w", err)
|
return nil, fmt.Errorf("could not generate CA private key: %w", err)
|
||||||
}
|
}
|
||||||
ca.signer = privateKey
|
ca.signer = privateKey
|
||||||
|
|
||||||
// Make a CA certificate valid for 100 years and backdated by one minute.
|
// Make a CA certificate valid for 100 years and backdated by one minute.
|
||||||
now := ca.clock()
|
now := env.clock()
|
||||||
notBefore := now.Add(-1 * time.Minute)
|
notBefore := now.Add(-1 * time.Minute)
|
||||||
notAfter := now.Add(24 * time.Hour * 365 * 100)
|
notAfter := now.Add(24 * time.Hour * 365 * 100)
|
||||||
|
|
||||||
@ -87,7 +114,7 @@ func New(subject pkix.Name, opts ...Option) (*CA, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-sign the CA to get the DER certificate.
|
// Self-sign the CA to get the DER certificate.
|
||||||
caCertBytes, err := x509.CreateCertificate(ca.signingRNG, &caTemplate, &caTemplate, &privateKey.PublicKey, privateKey)
|
caCertBytes, err := x509.CreateCertificate(env.signingRNG, &caTemplate, &caTemplate, &privateKey.PublicKey, privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not issue CA certificate: %w", err)
|
return nil, fmt.Errorf("could not issue CA certificate: %w", err)
|
||||||
}
|
}
|
||||||
@ -95,37 +122,27 @@ func New(subject pkix.Name, opts ...Option) (*CA, error) {
|
|||||||
return &ca, nil
|
return &ca, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteBundle writes the current CA signing bundle in concatenated PEM format.
|
|
||||||
func (c *CA) WriteBundle(out io.Writer) error {
|
|
||||||
if err := pem.Encode(out, &pem.Block{Type: "CERTIFICATE", Bytes: c.caCertBytes}); err != nil {
|
|
||||||
return fmt.Errorf("could not encode CA certificate to PEM: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle returns the current CA signing bundle in concatenated PEM format.
|
// Bundle returns the current CA signing bundle in concatenated PEM format.
|
||||||
func (c *CA) Bundle() ([]byte, error) {
|
func (c *CA) Bundle() []byte {
|
||||||
var out bytes.Buffer
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.caCertBytes})
|
||||||
err := c.WriteBundle(&out)
|
|
||||||
return out.Bytes(), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue a new server certificate for the given identity and duration.
|
// Issue a new server certificate for the given identity and duration.
|
||||||
func (c *CA) Issue(subject pkix.Name, dnsNames []string, ttl time.Duration) (*tls.Certificate, error) {
|
func (c *CA) Issue(subject pkix.Name, dnsNames []string, ttl time.Duration) (*tls.Certificate, error) {
|
||||||
// Choose a random 128 bit serial number.
|
// Choose a random 128 bit serial number.
|
||||||
serialNumber, err := randomSerial(c.serialRNG)
|
serialNumber, err := randomSerial(c.env.serialRNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not generate serial number for certificate: %w", err)
|
return nil, fmt.Errorf("could not generate serial number for certificate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new P256 keypair.
|
// Generate a new P256 keypair.
|
||||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), c.keygenRNG)
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), c.env.keygenRNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not generate private key: %w", err)
|
return nil, fmt.Errorf("could not generate private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make a CA caCert valid for the requested TTL and backdated by one minute.
|
// Make a CA caCert valid for the requested TTL and backdated by one minute.
|
||||||
now := c.clock()
|
now := c.env.clock()
|
||||||
notBefore := now.Add(-1 * time.Minute)
|
notBefore := now.Add(-1 * time.Minute)
|
||||||
notAfter := now.Add(ttl)
|
notAfter := now.Add(ttl)
|
||||||
|
|
||||||
@ -153,7 +170,7 @@ func (c *CA) Issue(subject pkix.Name, dnsNames []string, ttl time.Duration) (*tl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the DER encoded certificate back out into an *x509.Certificate.
|
// Parse the DER encoded certificate back out into an *x509.Certificate.
|
||||||
newCert, err := x509.ParseCertificate(certBytes)
|
newCert, err := c.env.parseCert(certBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not parse certificate: %w", err)
|
return nil, fmt.Errorf("could not parse certificate: %w", err)
|
||||||
}
|
}
|
||||||
@ -166,6 +183,35 @@ func (c *CA) Issue(subject pkix.Name, dnsNames []string, ttl time.Duration) (*tl
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IssuePEM issues a new server certificate for the given identity and duration, returning it as a pair of
|
||||||
|
// PEM-formatted byte slices for the certificate and private key.
|
||||||
|
func (c *CA) IssuePEM(subject pkix.Name, dnsNames []string, ttl time.Duration) ([]byte, []byte, error) {
|
||||||
|
return toPEM(c.Issue(subject, dnsNames, ttl))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPEM(cert *tls.Certificate, err error) ([]byte, []byte, error) {
|
||||||
|
// If the wrapped Issue() returned an error, pass it back.
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the certificate(s) to PEM.
|
||||||
|
certPEMBlocks := make([][]byte, 0, len(cert.Certificate))
|
||||||
|
for _, c := range cert.Certificate {
|
||||||
|
certPEMBlocks = append(certPEMBlocks, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c}))
|
||||||
|
}
|
||||||
|
certPEM := bytes.Join(certPEMBlocks, nil)
|
||||||
|
|
||||||
|
// Encode the private key to PEM, which means we first need to convert to PKCS8 (DER).
|
||||||
|
privateKeyPKCS8, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to marshal private key into PKCS8: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyPKCS8})
|
||||||
|
|
||||||
|
return certPEM, keyPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
// randomSerial generates a random 128 bit serial number.
|
// randomSerial generates a random 128 bit serial number.
|
||||||
func randomSerial(rng io.Reader) (*big.Int, error) {
|
func randomSerial(rng io.Reader) (*big.Int, error) {
|
||||||
return rand.Int(rng, new(big.Int).Lsh(big.NewInt(1), 128))
|
return rand.Int(rng, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
@ -6,8 +6,8 @@ SPDX-License-Identifier: Apache-2.0
|
|||||||
package certauthority
|
package certauthority
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto"
|
"crypto"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -19,65 +19,149 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
now := time.Date(2020, 7, 10, 12, 41, 12, 1234, time.UTC)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
opts []Option
|
certPath string
|
||||||
wantErr string
|
keyPath string
|
||||||
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "error option",
|
name: "missing cert",
|
||||||
opts: []Option{func(ca *CA) error {
|
certPath: "./testdata/cert-does-not-exist",
|
||||||
return fmt.Errorf("some error")
|
keyPath: "./testdata/test.key",
|
||||||
}},
|
wantErr: "could not load CA: open ./testdata/cert-does-not-exist: no such file or directory",
|
||||||
wantErr: "some error",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "failed to generate CA serial",
|
name: "empty cert",
|
||||||
opts: []Option{func(ca *CA) error {
|
certPath: "./testdata/empty",
|
||||||
ca.serialRNG = strings.NewReader("")
|
keyPath: "./testdata/test.key",
|
||||||
ca.keygenRNG = strings.NewReader("")
|
wantErr: "could not load CA: tls: failed to find any PEM data in certificate input",
|
||||||
ca.signingRNG = strings.NewReader("")
|
|
||||||
return nil
|
|
||||||
}},
|
|
||||||
wantErr: "could not generate CA serial: EOF",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "failed to generate CA key",
|
name: "invalid cert",
|
||||||
opts: []Option{func(ca *CA) error {
|
certPath: "./testdata/invalid",
|
||||||
ca.serialRNG = strings.NewReader(strings.Repeat("x", 64))
|
keyPath: "./testdata/test.key",
|
||||||
ca.keygenRNG = strings.NewReader("")
|
wantErr: "could not load CA: tls: failed to find any PEM data in certificate input",
|
||||||
return nil
|
|
||||||
}},
|
|
||||||
wantErr: "could not generate CA private key: EOF",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "failed to self-sign",
|
name: "missing key",
|
||||||
opts: []Option{func(ca *CA) error {
|
certPath: "./testdata/test.crt",
|
||||||
ca.serialRNG = strings.NewReader(strings.Repeat("x", 64))
|
keyPath: "./testdata/key-does-not-exist",
|
||||||
ca.keygenRNG = strings.NewReader(strings.Repeat("y", 64))
|
wantErr: "could not load CA: open ./testdata/key-does-not-exist: no such file or directory",
|
||||||
ca.signingRNG = strings.NewReader("")
|
|
||||||
return nil
|
|
||||||
}},
|
|
||||||
wantErr: "could not issue CA certificate: EOF",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "success",
|
name: "empty key",
|
||||||
opts: []Option{func(ca *CA) error {
|
certPath: "./testdata/test.crt",
|
||||||
ca.serialRNG = strings.NewReader(strings.Repeat("x", 64))
|
keyPath: "./testdata/empty",
|
||||||
ca.keygenRNG = strings.NewReader(strings.Repeat("y", 64))
|
wantErr: "could not load CA: tls: failed to find any PEM data in key input",
|
||||||
ca.signingRNG = strings.NewReader(strings.Repeat("z", 64))
|
},
|
||||||
ca.clock = func() time.Time { return now }
|
{
|
||||||
return nil
|
name: "invalid key",
|
||||||
}},
|
certPath: "./testdata/test.crt",
|
||||||
|
keyPath: "./testdata/invalid",
|
||||||
|
wantErr: "could not load CA: tls: failed to find any PEM data in key input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mismatched cert and key",
|
||||||
|
certPath: "./testdata/test.crt",
|
||||||
|
keyPath: "./testdata/test2.key",
|
||||||
|
wantErr: "could not load CA: tls: private key does not match public key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple certs",
|
||||||
|
certPath: "./testdata/multiple.crt",
|
||||||
|
keyPath: "./testdata/test.key",
|
||||||
|
wantErr: "invalid CA certificate: expected a single certificate, found 2 certificates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
certPath: "./testdata/test.crt",
|
||||||
|
keyPath: "./testdata/test.key",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
tt := tt
|
tt := tt
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := New(pkix.Name{CommonName: "Test CA"}, tt.opts...)
|
ca, err := Load(tt.certPath, tt.keyPath)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.EqualError(t, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, ca.caCertBytes)
|
||||||
|
require.NotNil(t, ca.signer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
got, err := New(pkix.Name{CommonName: "Test CA"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
|
||||||
|
// Make sure the CA certificate looks roughly like what we expect.
|
||||||
|
caCert, err := x509.ParseCertificate(got.caCertBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Test CA", caCert.Subject.CommonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewInternal(t *testing.T) {
|
||||||
|
now := time.Date(2020, 7, 10, 12, 41, 12, 1234, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env env
|
||||||
|
wantErr string
|
||||||
|
wantCommonName string
|
||||||
|
wantNotBefore time.Time
|
||||||
|
wantNotAfter time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "failed to generate CA serial",
|
||||||
|
env: env{
|
||||||
|
serialRNG: strings.NewReader(""),
|
||||||
|
keygenRNG: strings.NewReader(""),
|
||||||
|
signingRNG: strings.NewReader(""),
|
||||||
|
},
|
||||||
|
wantErr: "could not generate CA serial: EOF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to generate CA key",
|
||||||
|
env: env{
|
||||||
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
keygenRNG: strings.NewReader(""),
|
||||||
|
signingRNG: strings.NewReader(""),
|
||||||
|
},
|
||||||
|
wantErr: "could not generate CA private key: EOF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to self-sign",
|
||||||
|
env: env{
|
||||||
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
keygenRNG: strings.NewReader(strings.Repeat("y", 64)),
|
||||||
|
signingRNG: strings.NewReader(""),
|
||||||
|
clock: func() time.Time { return now },
|
||||||
|
},
|
||||||
|
wantErr: "could not issue CA certificate: EOF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
env: env{
|
||||||
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
keygenRNG: strings.NewReader(strings.Repeat("y", 64)),
|
||||||
|
signingRNG: strings.NewReader(strings.Repeat("z", 64)),
|
||||||
|
clock: func() time.Time { return now },
|
||||||
|
},
|
||||||
|
wantCommonName: "Test CA",
|
||||||
|
wantNotAfter: now.Add(100 * 365 * 24 * time.Hour),
|
||||||
|
wantNotBefore: now.Add(-1 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := newInternal(pkix.Name{CommonName: "Test CA"}, tt.env)
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
require.EqualError(t, err, tt.wantErr)
|
require.EqualError(t, err, tt.wantErr)
|
||||||
require.Nil(t, got)
|
require.Nil(t, got)
|
||||||
@ -89,46 +173,17 @@ func TestNew(t *testing.T) {
|
|||||||
// Make sure the CA certificate looks roughly like what we expect.
|
// Make sure the CA certificate looks roughly like what we expect.
|
||||||
caCert, err := x509.ParseCertificate(got.caCertBytes)
|
caCert, err := x509.ParseCertificate(got.caCertBytes)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "Test CA", caCert.Subject.CommonName)
|
require.Equal(t, tt.wantCommonName, caCert.Subject.CommonName)
|
||||||
require.Equal(t, now.Add(100*365*24*time.Hour).Unix(), caCert.NotAfter.Unix())
|
require.Equal(t, tt.wantNotAfter.Unix(), caCert.NotAfter.Unix())
|
||||||
require.Equal(t, now.Add(-1*time.Minute).Unix(), caCert.NotBefore.Unix())
|
require.Equal(t, tt.wantNotBefore.Unix(), caCert.NotBefore.Unix())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type errWriter struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *errWriter) Write(p []byte) (n int, err error) { return 0, e.err }
|
|
||||||
|
|
||||||
func TestWriteBundle(t *testing.T) {
|
|
||||||
t.Run("error", func(t *testing.T) {
|
|
||||||
ca := CA{}
|
|
||||||
out := errWriter{fmt.Errorf("some error")}
|
|
||||||
require.EqualError(t, ca.WriteBundle(&out), "could not encode CA certificate to PEM: some error")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty", func(t *testing.T) {
|
|
||||||
ca := CA{}
|
|
||||||
var out bytes.Buffer
|
|
||||||
require.NoError(t, ca.WriteBundle(&out))
|
|
||||||
require.Equal(t, "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n", out.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("success", func(t *testing.T) {
|
|
||||||
ca := CA{caCertBytes: []byte{1, 2, 3, 4, 5, 6, 7, 8}}
|
|
||||||
var out bytes.Buffer
|
|
||||||
require.NoError(t, ca.WriteBundle(&out))
|
|
||||||
require.Equal(t, "-----BEGIN CERTIFICATE-----\nAQIDBAUGBwg=\n-----END CERTIFICATE-----\n", out.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBundle(t *testing.T) {
|
func TestBundle(t *testing.T) {
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
ca := CA{caCertBytes: []byte{1, 2, 3, 4, 5, 6, 7, 8}}
|
ca := CA{caCertBytes: []byte{1, 2, 3, 4, 5, 6, 7, 8}}
|
||||||
got, err := ca.Bundle()
|
got := ca.Bundle()
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "-----BEGIN CERTIFICATE-----\nAQIDBAUGBwg=\n-----END CERTIFICATE-----\n", string(got))
|
require.Equal(t, "-----BEGIN CERTIFICATE-----\nAQIDBAUGBwg=\n-----END CERTIFICATE-----\n", string(got))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -147,7 +202,7 @@ func (e *errSigner) Sign(_ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, er
|
|||||||
func TestIssue(t *testing.T) {
|
func TestIssue(t *testing.T) {
|
||||||
now := time.Date(2020, 7, 10, 12, 41, 12, 1234, time.UTC)
|
now := time.Date(2020, 7, 10, 12, 41, 12, 1234, time.UTC)
|
||||||
|
|
||||||
realCA, err := New(pkix.Name{CommonName: "Test CA"})
|
realCA, err := Load("./testdata/test.crt", "./testdata/test.key")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@ -158,33 +213,41 @@ func TestIssue(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "failed to generate serial",
|
name: "failed to generate serial",
|
||||||
ca: CA{
|
ca: CA{
|
||||||
serialRNG: strings.NewReader(""),
|
env: env{
|
||||||
|
serialRNG: strings.NewReader(""),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wantErr: "could not generate serial number for certificate: EOF",
|
wantErr: "could not generate serial number for certificate: EOF",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "failed to generate keypair",
|
name: "failed to generate keypair",
|
||||||
ca: CA{
|
ca: CA{
|
||||||
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
env: env{
|
||||||
keygenRNG: strings.NewReader(""),
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
keygenRNG: strings.NewReader(""),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wantErr: "could not generate private key: EOF",
|
wantErr: "could not generate private key: EOF",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid CA certificate",
|
name: "invalid CA certificate",
|
||||||
ca: CA{
|
ca: CA{
|
||||||
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
env: env{
|
||||||
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
clock: func() time.Time { return now },
|
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
clock: func() time.Time { return now },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wantErr: "could not parse CA certificate: asn1: syntax error: sequence truncated",
|
wantErr: "could not parse CA certificate: asn1: syntax error: sequence truncated",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "signing error",
|
name: "signing error",
|
||||||
ca: CA{
|
ca: CA{
|
||||||
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
env: env{
|
||||||
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
clock: func() time.Time { return now },
|
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
clock: func() time.Time { return now },
|
||||||
|
},
|
||||||
caCertBytes: realCA.caCertBytes,
|
caCertBytes: realCA.caCertBytes,
|
||||||
signer: &errSigner{
|
signer: &errSigner{
|
||||||
pubkey: realCA.signer.Public(),
|
pubkey: realCA.signer.Public(),
|
||||||
@ -196,9 +259,28 @@ func TestIssue(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "success",
|
name: "success",
|
||||||
ca: CA{
|
ca: CA{
|
||||||
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
env: env{
|
||||||
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
clock: func() time.Time { return now },
|
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
clock: func() time.Time { return now },
|
||||||
|
parseCert: func(_ []byte) (*x509.Certificate, error) {
|
||||||
|
return nil, fmt.Errorf("some parse certificate error")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
caCertBytes: realCA.caCertBytes,
|
||||||
|
signer: realCA.signer,
|
||||||
|
},
|
||||||
|
wantErr: "could not parse certificate: some parse certificate error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
ca: CA{
|
||||||
|
env: env{
|
||||||
|
serialRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
keygenRNG: strings.NewReader(strings.Repeat("x", 64)),
|
||||||
|
clock: func() time.Time { return now },
|
||||||
|
parseCert: x509.ParseCertificate,
|
||||||
|
},
|
||||||
caCertBytes: realCA.caCertBytes,
|
caCertBytes: realCA.caCertBytes,
|
||||||
signer: realCA.signer,
|
signer: realCA.signer,
|
||||||
},
|
},
|
||||||
@ -218,3 +300,41 @@ func TestIssue(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIssuePEM(t *testing.T) {
|
||||||
|
realCA, err := Load("./testdata/test.crt", "./testdata/test.key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
certPEM, keyPEM, err := realCA.IssuePEM(pkix.Name{CommonName: "Test Server"}, []string{"example.com"}, 10*time.Minute)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, certPEM)
|
||||||
|
require.NotEmpty(t, keyPEM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToPEM(t *testing.T) {
|
||||||
|
realCert, err := tls.LoadX509KeyPair("./testdata/test.crt", "./testdata/test.key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("error from input", func(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := toPEM(nil, fmt.Errorf("some error"))
|
||||||
|
require.EqualError(t, err, "some error")
|
||||||
|
require.Nil(t, certPEM)
|
||||||
|
require.Nil(t, keyPEM)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid private key", func(t *testing.T) {
|
||||||
|
cert := realCert
|
||||||
|
cert.PrivateKey = nil
|
||||||
|
certPEM, keyPEM, err := toPEM(&cert, nil)
|
||||||
|
require.EqualError(t, err, "failed to marshal private key into PKCS8: x509: unknown key type while marshaling PKCS#8: <nil>")
|
||||||
|
require.Nil(t, certPEM)
|
||||||
|
require.Nil(t, keyPEM)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
certPEM, keyPEM, err := toPEM(&realCert, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, certPEM)
|
||||||
|
require.NotEmpty(t, keyPEM)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
0
internal/certauthority/testdata/empty
vendored
Normal file
0
internal/certauthority/testdata/empty
vendored
Normal file
1
internal/certauthority/testdata/invalid
vendored
Normal file
1
internal/certauthority/testdata/invalid
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
Some invalid file contents
|
34
internal/certauthority/testdata/multiple.crt
vendored
Normal file
34
internal/certauthority/testdata/multiple.crt
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICyDCCAbCgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl
|
||||||
|
cm5ldGVzMB4XDTIwMDcyNTIxMDQxOFoXDTMwMDcyMzIxMDQxOFowFTETMBEGA1UE
|
||||||
|
AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3K
|
||||||
|
hYv2gIQ1Dwzh2cWMid+ofAnvLIfV2Xv61vTLGprUI+XUqB4/gtf6X6UNn0Lett2n
|
||||||
|
d8p4wy7hw73hU/ggdvmWJvqBrSjc3JGfy+kj66fKXX+PTlbL7QbwiRvcSqIXIWlV
|
||||||
|
lHHxECWrED8jCulw/NVqfook/h5iNUCT9yswSJr/0fImiVnoTlIoEYG2eCNejZ5c
|
||||||
|
g39uD3ZTqd9ZxWwSLLnI+2kpJnZBPcd1ZQ8AQqzDgZtYRCqacn5gckQUKZWKQlxo
|
||||||
|
Eft6g1XHJouAWAZw7hEtk0v8rG0/eKF7wamxFi6BFVlbjWBsB4T9rApbdBWTKeCJ
|
||||||
|
Hv8fv5RMFSzpT3uzTO8CAwEAAaMjMCEwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB
|
||||||
|
/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBACh5RhbxqJe+Z/gc17cZhKNmdiwu
|
||||||
|
I2pLp3QBfwvN+Wbmajzw/7rYhY0d8JYVTJzXSCPWi6UAKxAtXOLF8WIIf9i39n6R
|
||||||
|
uKOBGW14FzzGyRJiD3qaG/JTvEW+SLhwl68Ndr5LHSnbugAqq31abcQy6Zl9v5A8
|
||||||
|
JKC97Lj/Sn8rj7opKy4W3oq7NCQsAb0zh4IllRF6UvSnJySfsg7xdXHHpxYDHtOS
|
||||||
|
XcOu5ySUIZTgFe9RfeUZlGZ5xn0ckMlQ7qW2Wx1q0OVWw5us4NtkGqKrHG4Tn1X7
|
||||||
|
uwo/Yytn5sDxrDv1/oii6AZOCsTPre4oD3wz4nmVzCVJcgrqH4Q24hT8WNg=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICyzCCAbOgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl
|
||||||
|
cm5ldGVzMB4XDTIwMDcyMTIxMDcwMloXDTMwMDcxOTIxMTIwMlowFTETMBEGA1UE
|
||||||
|
AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALRz
|
||||||
|
y8TRjCTkqxTGpMoGa6nmZAZU76GqchVJh6x/T/utsfE822IgyLo5oNKKtLsNiXOT
|
||||||
|
pO1DdaPtwKJBlifmAIfPwPIOikcqYicLYMS6PM1f6pPGUM9zlYW198sQfTybVVQG
|
||||||
|
KBLEjyYbcFPtM98omXaILM+V8KXokMiSWgho36qH/abcdxKKTPJs44U+mby5gkew
|
||||||
|
Iee+8yXOiqCmCPc6a+UsU28eYt72XTiAMLKQsGu71Q99IMXYq6/kZbxxFk8zh2r+
|
||||||
|
ZuaBbCbasclznutLnWB4hpfswLnTY3Ct9kv07mjzmIsLguOOQGzoVJ72xsLwxfoG
|
||||||
|
KS5jnTImXFVp28NFP6kCAwEAAaMmMCQwDgYDVR0PAQH/BAQDAgKkMBIGA1UdEwEB
|
||||||
|
/wQIMAYBAf8CAQAwDQYJKoZIhvcNAQELBQADggEBAF4E3IA8Wst9GOeFjjv/IPAp
|
||||||
|
LT89JHehMrTRMngw+Lvv4WG9rymtHFpvEiVgh8MhcP+MuNc95tIPLaonpTdKJ/dR
|
||||||
|
PgDlpOFL0dvaskuOVWQYZnD/ehf8zF+vlt9Mby3rJleruGETahdvQVd7DB61zSC6
|
||||||
|
7g2rE2VqZ62Vj48RdEF9n+tYXWa4wRw3I1i8DUA/fNAS8P4/KXN6VvdVHe8y+nim
|
||||||
|
ASs8VX0QEWaGRIQZankFXv5p1/tXOFo/Wmo8vrVa9OsCXJm7iN5riwYiQn237iBJ
|
||||||
|
XmEs5jp04H7V7aDqZOX/N13uzQbGSPChAgWr/LxO1tIa6PmK+vfo60lRsBWeYrE=
|
||||||
|
-----END CERTIFICATE-----
|
17
internal/certauthority/testdata/test.crt
vendored
Normal file
17
internal/certauthority/testdata/test.crt
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICyDCCAbCgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl
|
||||||
|
cm5ldGVzMB4XDTIwMDcyNTIxMDQxOFoXDTMwMDcyMzIxMDQxOFowFTETMBEGA1UE
|
||||||
|
AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3K
|
||||||
|
hYv2gIQ1Dwzh2cWMid+ofAnvLIfV2Xv61vTLGprUI+XUqB4/gtf6X6UNn0Lett2n
|
||||||
|
d8p4wy7hw73hU/ggdvmWJvqBrSjc3JGfy+kj66fKXX+PTlbL7QbwiRvcSqIXIWlV
|
||||||
|
lHHxECWrED8jCulw/NVqfook/h5iNUCT9yswSJr/0fImiVnoTlIoEYG2eCNejZ5c
|
||||||
|
g39uD3ZTqd9ZxWwSLLnI+2kpJnZBPcd1ZQ8AQqzDgZtYRCqacn5gckQUKZWKQlxo
|
||||||
|
Eft6g1XHJouAWAZw7hEtk0v8rG0/eKF7wamxFi6BFVlbjWBsB4T9rApbdBWTKeCJ
|
||||||
|
Hv8fv5RMFSzpT3uzTO8CAwEAAaMjMCEwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB
|
||||||
|
/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBACh5RhbxqJe+Z/gc17cZhKNmdiwu
|
||||||
|
I2pLp3QBfwvN+Wbmajzw/7rYhY0d8JYVTJzXSCPWi6UAKxAtXOLF8WIIf9i39n6R
|
||||||
|
uKOBGW14FzzGyRJiD3qaG/JTvEW+SLhwl68Ndr5LHSnbugAqq31abcQy6Zl9v5A8
|
||||||
|
JKC97Lj/Sn8rj7opKy4W3oq7NCQsAb0zh4IllRF6UvSnJySfsg7xdXHHpxYDHtOS
|
||||||
|
XcOu5ySUIZTgFe9RfeUZlGZ5xn0ckMlQ7qW2Wx1q0OVWw5us4NtkGqKrHG4Tn1X7
|
||||||
|
uwo/Yytn5sDxrDv1/oii6AZOCsTPre4oD3wz4nmVzCVJcgrqH4Q24hT8WNg=
|
||||||
|
-----END CERTIFICATE-----
|
27
internal/certauthority/testdata/test.key
vendored
Normal file
27
internal/certauthority/testdata/test.key
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEogIBAAKCAQEAvcqFi/aAhDUPDOHZxYyJ36h8Ce8sh9XZe/rW9MsamtQj5dSo
|
||||||
|
Hj+C1/pfpQ2fQt623ad3ynjDLuHDveFT+CB2+ZYm+oGtKNzckZ/L6SPrp8pdf49O
|
||||||
|
VsvtBvCJG9xKohchaVWUcfEQJasQPyMK6XD81Wp+iiT+HmI1QJP3KzBImv/R8iaJ
|
||||||
|
WehOUigRgbZ4I16NnlyDf24PdlOp31nFbBIsucj7aSkmdkE9x3VlDwBCrMOBm1hE
|
||||||
|
KppyfmByRBQplYpCXGgR+3qDVccmi4BYBnDuES2TS/ysbT94oXvBqbEWLoEVWVuN
|
||||||
|
YGwHhP2sClt0FZMp4Ike/x+/lEwVLOlPe7NM7wIDAQABAoIBAFC1tUEmHNUcM0BJ
|
||||||
|
M3D9KQzB+63F1mwVlx1QOOV1EeVR3co5Ox1R6PSr9sycFGQ9jgqI0zp5TJe9Tp6L
|
||||||
|
GkhklfPh1MWnK9o6wlnzWKXWrrp2Jni+mpPyuOPAmq4Maniv2XeP+0bROwqpyojv
|
||||||
|
AA7yC7M+TH226ZJGNVs3EV9+cwHml0yuzBfIJn/rv/w2g+WRKM/MC0S7k2d8bRlA
|
||||||
|
NycKVGAGBhKTltjoVYOeh6aHEpSjK8zfaePjo5dYJvoVIli60YCgcJOU/8jXT+Np
|
||||||
|
1Fm7tRvAtj3pUp0Sqdaf2RUzh9jfJp2VFCHuSJ6TPqArOyQojtMcTHF0TiW7xrHP
|
||||||
|
xOCRIAECgYEAwGBPU7vdthMJBg+ORUoGQQaItTeJvQwIqJvbKD2osp4jhS1dGZBw
|
||||||
|
W30GKEc/gd8JNtOq9BBnMicPF7hktuy+bSPv41XPud67rSSO7Tsw20C10gFRq06B
|
||||||
|
zIJWFAUqK3IkvVc3VDmtSLSDox4QZ/BdqaMlQ5y5JCsC5kThmkZFlO8CgYEA/I9X
|
||||||
|
YHi6RioMJE1fqOHJL4DDjlezmcuRrD7fE5InKbtJZ2JhGYOX/C0KXnHTOWTCDxxN
|
||||||
|
FBvpvD6Xv5o3PhB9Z6k2fqvJ4GS8urkG/KU4xcC+bak+9ava8oaiSqG16zD9NH2P
|
||||||
|
jJ60NrbLl1J0pU9fiwuFVUKJ4hDZOfN9RqYdyAECgYAVwo8WhJiGgM6zfcz073OX
|
||||||
|
pVqPTPHqjVLpZ3+5pIfRdGvGI6R1QM5EuvaYVb7MPOM47WZX5wcVOC/P2g6iVlMP
|
||||||
|
21HGIC2384a9BfaYxOo40q/+SiHnw6CQ9mkwKIllkqqvNA9RGpkMMUb2i28For2l
|
||||||
|
c4vCgxa6DZdtXns6TRqPxwKBgCfY5cxOv/T6BVhk7MbUeM2J31DB/ZAyUhV/Bess
|
||||||
|
kAlBh19MYk2IOZ6L7KriApV3lDaWHIMjtEkDByYvyq98Io0MYZCywfMpca10K+oI
|
||||||
|
l2B7/I+IuGpCZxUEsO5dfTpSTGDPvqpND9niFVUWqVi7oTNq6ep9yQtl5SADjqxq
|
||||||
|
4SABAoGAIm0hUg1wtcS46cGLy6PIkPM5tocTSghtz4vFsuk/i4QA9GBoBO2gH6ty
|
||||||
|
+kJHmeaXt2dmgySp0QAWit5UlceEumB0NXnAdJZQxeGSFSyYkDWhwXd8wDceKo/1
|
||||||
|
LfCU6Dk8IN/SsppVUWXQ2rlORvxlrHeCio8o0kS9Yiu55WMYg4g=
|
||||||
|
-----END RSA PRIVATE KEY-----
|
27
internal/certauthority/testdata/test2.key
vendored
Normal file
27
internal/certauthority/testdata/test2.key
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAqeddVCjzZNKp/hVprYRY4EKos0PkrpkA0cbQTIhKrJIU2JXW
|
||||||
|
G0/RgffNnfMEXGR/6xhGPGM9CU+jD5aZJtl/+7w0zhlZib1lQLeSIfOLGO4rqbsr
|
||||||
|
k4KXYosUaUD8qd6El1FXUB6OfpZFjmvE5qzFlpjOEbedDrgbzVeD6BsdeCrgT3G4
|
||||||
|
Jly/79MWsOjGk8Oa+OS/4OLf6W0UnKCWbyENNDVrsihLByffv1YZZ+s4bOMFb1cA
|
||||||
|
Oc078TF/1M5ekTcicGdKKxr7CQT9RtR6iuPcVucuBhd9eDCsK4tH9PEWtyY/hISz
|
||||||
|
Pnrigt6LEW++IvHLJQ7mX+COzFMNramyK/F8aQIDAQABAoIBAFUd02OWIFkiMIdZ
|
||||||
|
std6tgujWWB1YtsVS5PMRg4ROVe61zap2dlU42B5BElctZKTxoHAZ29ZR/qiKs5k
|
||||||
|
Y9VSoQs7/jhB+tlGSLNjQ5I+sDCNINKnMe10PuLfShpwtCNlloc3+MXqiPhhz/bJ
|
||||||
|
hpsJcvM/Gf1GPyhgk40Lisl8zAamouLZ0qQSspmHmPo4ztpA2w81bG05GfTJTqVp
|
||||||
|
6pceOotVU4NNkiqq0SOF13ZajUaR3ifCnEbjkRnh8L8x0rqGUv7aAZMIc6x3kHRT
|
||||||
|
ZG2HfLvQW8QWsOWB6WIdZqAPMtrTJw0ozMfLJYfkfosGjLC99pjefMY7ENPnGwvN
|
||||||
|
FdOgwAECgYEA3WtNyi9bE5Licrv0Ys/A3oI11in2eKL8cmeJM/Hxvxz3EzQH8Kl8
|
||||||
|
nYtfCP18kHSg7fddg4xbqW6NLPIkOtopDy0evTzPpZpxBc6mu1buqFm4EBeUAsIu
|
||||||
|
rK1Ol0dKxZ7EoFAQGHN0JpTd62diRHnxCiIvfQD55ClloyrR/Cd8SuECgYEAxHBk
|
||||||
|
YsDSFs9DFbhvFEWigfPPYjViHg9FsNBYodtuiyWXbkH8QXmjTpEaiLXk5Zc0VOYA
|
||||||
|
8OBYbxly1l6nAW9/v2LewPoxVQTyTnTnsLlarLo2ax8GT1NNu9gCPGzbEORvlHky
|
||||||
|
h/NCw0RBcmGnfTijoOVEC8RETbN1SK3x5as0qokCgYAM3myyAJiZhaL1qijlCVAb
|
||||||
|
XpQEc4HotwhXGd9mjnxPcD6H9jEz8pXUjkIiwqDXwH+N9R+RQrodGdjIsPYcGYvj
|
||||||
|
Xur3cq5a4KQLA1y7bK0ISdah0M0AcArIbHYx4qnc3IJvEtgso6EvkN1pDiQu+Kti
|
||||||
|
vGPoLwNXGHTYy+dScXUO4QKBgQCmgFFGNwOb28+T0IEuYJuOpJZKOs9QhUdfyCjo
|
||||||
|
ADMhdBp3lSx4Xt6h0HH6IJrEU7ZCo7V2deHfQWXJ9+58VAKmuOnwDeDUnF25THO5
|
||||||
|
olIOB8PqZiCWChjgOAYlK2s/VTCSW2wOOY2ELw1+IvGxPNnMnadghdoTNiIaGX3o
|
||||||
|
WoZIaQKBgEDojPr84GMR+M5d2YrvLJmLSmDLwWR0j6soANUXsaLzZVZBifOSKIG8
|
||||||
|
ySO4QOe4O2Nb1nWAfmEP964VwoQ0rHlkt0rJopwJTj4uYjhDRGIN+x2aJZ00bm4u
|
||||||
|
/1c7cowqxj/TkjTP4JASxBwBPUYpJxiajATTZiddxt4xRxaNVKlK
|
||||||
|
-----END RSA PRIVATE KEY-----
|
8
internal/mocks/mockcertissuer/generate.go
Normal file
8
internal/mocks/mockcertissuer/generate.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2020 VMware, Inc.
|
||||||
|
SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package mockcertissuer
|
||||||
|
|
||||||
|
//go:generate go run -v github.com/golang/mock/mockgen -destination=mockcertissuer.go -package=mockcertissuer -copyright_file=../../../hack/header.txt github.com/suzerain-io/placeholder-name/pkg/registry/loginrequest CertIssuer
|
54
internal/mocks/mockcertissuer/mockcertissuer.go
Normal file
54
internal/mocks/mockcertissuer/mockcertissuer.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2020 VMware, Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
|
// Source: github.com/suzerain-io/placeholder-name/pkg/registry/loginrequest (interfaces: CertIssuer)
|
||||||
|
|
||||||
|
// Package mockcertissuer is a generated GoMock package.
|
||||||
|
package mockcertissuer
|
||||||
|
|
||||||
|
import (
|
||||||
|
pkix "crypto/x509/pkix"
|
||||||
|
gomock "github.com/golang/mock/gomock"
|
||||||
|
reflect "reflect"
|
||||||
|
time "time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockCertIssuer is a mock of CertIssuer interface
|
||||||
|
type MockCertIssuer struct {
|
||||||
|
ctrl *gomock.Controller
|
||||||
|
recorder *MockCertIssuerMockRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockCertIssuerMockRecorder is the mock recorder for MockCertIssuer
|
||||||
|
type MockCertIssuerMockRecorder struct {
|
||||||
|
mock *MockCertIssuer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockCertIssuer creates a new mock instance
|
||||||
|
func NewMockCertIssuer(ctrl *gomock.Controller) *MockCertIssuer {
|
||||||
|
mock := &MockCertIssuer{ctrl: ctrl}
|
||||||
|
mock.recorder = &MockCertIssuerMockRecorder{mock}
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPECT returns an object that allows the caller to indicate expected use
|
||||||
|
func (m *MockCertIssuer) EXPECT() *MockCertIssuerMockRecorder {
|
||||||
|
return m.recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuePEM mocks base method
|
||||||
|
func (m *MockCertIssuer) IssuePEM(arg0 pkix.Name, arg1 []string, arg2 time.Duration) ([]byte, []byte, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "IssuePEM", arg0, arg1, arg2)
|
||||||
|
ret0, _ := ret[0].([]byte)
|
||||||
|
ret1, _ := ret[1].([]byte)
|
||||||
|
ret2, _ := ret[2].(error)
|
||||||
|
return ret0, ret1, ret2
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuePEM indicates an expected call of IssuePEM
|
||||||
|
func (mr *MockCertIssuerMockRecorder) IssuePEM(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssuePEM", reflect.TypeOf((*MockCertIssuer)(nil).IssuePEM), arg0, arg1, arg2)
|
||||||
|
}
|
@ -57,6 +57,7 @@ type Config struct {
|
|||||||
|
|
||||||
type ExtraConfig struct {
|
type ExtraConfig struct {
|
||||||
Webhook authenticator.Token
|
Webhook authenticator.Token
|
||||||
|
Issuer loginrequest.CertIssuer
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaceHolderServer struct {
|
type PlaceHolderServer struct {
|
||||||
@ -108,7 +109,7 @@ func (c completedConfig) New() (*PlaceHolderServer, error) {
|
|||||||
NegotiatedSerializer: Codecs,
|
NegotiatedSerializer: Codecs,
|
||||||
}
|
}
|
||||||
|
|
||||||
loginRequestStorage := loginrequest.NewREST(c.ExtraConfig.Webhook)
|
loginRequestStorage := loginrequest.NewREST(c.ExtraConfig.Webhook, c.ExtraConfig.Issuer)
|
||||||
|
|
||||||
v1alpha1Storage, ok := apiGroupInfo.VersionedResourcesStorageMap[gvr.Version]
|
v1alpha1Storage, ok := apiGroupInfo.VersionedResourcesStorageMap[gvr.Version]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -8,7 +8,9 @@ package loginrequest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@ -28,14 +30,20 @@ var (
|
|||||||
_ rest.Storage = &REST{}
|
_ rest.Storage = &REST{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewREST(webhook authenticator.Token) *REST {
|
type CertIssuer interface {
|
||||||
|
IssuePEM(subject pkix.Name, dnsNames []string, ttl time.Duration) ([]byte, []byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewREST(webhook authenticator.Token, issuer CertIssuer) *REST {
|
||||||
return &REST{
|
return &REST{
|
||||||
webhook: webhook,
|
webhook: webhook,
|
||||||
|
issuer: issuer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type REST struct {
|
type REST struct {
|
||||||
webhook authenticator.Token
|
webhook authenticator.Token
|
||||||
|
issuer CertIssuer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *REST) New() runtime.Object {
|
func (r *REST) New() runtime.Object {
|
||||||
@ -110,32 +118,36 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
|
|||||||
klog.Warningf("webhook authentication failure: %v", err)
|
klog.Warningf("webhook authentication failure: %v", err)
|
||||||
return failureResponse(), nil
|
return failureResponse(), nil
|
||||||
}
|
}
|
||||||
|
if !authenticated || authResponse.User == nil || authResponse.User.GetName() == "" {
|
||||||
var out *placeholderapi.LoginRequest
|
return failureResponse(), nil
|
||||||
if authenticated && authResponse.User != nil && authResponse.User.GetName() != "" {
|
|
||||||
out = successfulResponse(authResponse)
|
|
||||||
} else {
|
|
||||||
out = failureResponse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
certPEM, keyPEM, err := r.issuer.IssuePEM(
|
||||||
}
|
pkix.Name{
|
||||||
|
CommonName: authResponse.User.GetName(),
|
||||||
|
OrganizationalUnit: authResponse.User.GetGroups(),
|
||||||
|
},
|
||||||
|
[]string{},
|
||||||
|
5*time.Minute,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
klog.Warningf("failed to issue short lived client certificate: %v", err)
|
||||||
|
return failureResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func successfulResponse(authResponse *authenticator.Response) *placeholderapi.LoginRequest {
|
|
||||||
return &placeholderapi.LoginRequest{
|
return &placeholderapi.LoginRequest{
|
||||||
Status: placeholderapi.LoginRequestStatus{
|
Status: placeholderapi.LoginRequestStatus{
|
||||||
Credential: &placeholderapi.LoginRequestCredential{
|
Credential: &placeholderapi.LoginRequestCredential{
|
||||||
ExpirationTimestamp: nil,
|
ExpirationTimestamp: nil,
|
||||||
Token: "snorlax",
|
ClientCertificateData: string(certPEM),
|
||||||
ClientCertificateData: "",
|
ClientKeyData: string(keyPEM),
|
||||||
ClientKeyData: "",
|
|
||||||
},
|
},
|
||||||
User: &placeholderapi.User{
|
User: &placeholderapi.User{
|
||||||
Name: authResponse.User.GetName(),
|
Name: authResponse.User.GetName(),
|
||||||
Groups: authResponse.User.GetGroups(),
|
Groups: authResponse.User.GetGroups(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func failureResponse() *placeholderapi.LoginRequest {
|
func failureResponse() *placeholderapi.LoginRequest {
|
||||||
|
@ -7,11 +7,13 @@ package loginrequest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@ -22,6 +24,7 @@ import (
|
|||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
||||||
|
"github.com/suzerain-io/placeholder-name/internal/mocks/mockcertissuer"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey struct{}
|
type contextKey struct{}
|
||||||
@ -113,7 +116,18 @@ func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func successfulIssuer(ctrl *gomock.Controller) CertIssuer {
|
||||||
|
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
|
||||||
|
issuer.EXPECT().
|
||||||
|
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
Return([]byte("test-cert"), []byte("test-key"), nil)
|
||||||
|
return issuer
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *testing.T) {
|
func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: &authenticator.Response{
|
returnResponse: &authenticator.Response{
|
||||||
User: &user.DefaultInfo{
|
User: &user.DefaultInfo{
|
||||||
@ -123,7 +137,17 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
|
|||||||
},
|
},
|
||||||
returnUnauthenticated: false,
|
returnUnauthenticated: false,
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
|
||||||
|
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
|
||||||
|
issuer.EXPECT().IssuePEM(
|
||||||
|
pkix.Name{
|
||||||
|
CommonName: "test-user",
|
||||||
|
OrganizationalUnit: []string{"test-group-1", "test-group-2"}},
|
||||||
|
[]string{},
|
||||||
|
5*time.Minute,
|
||||||
|
).Return([]byte("test-cert"), []byte("test-key"), nil)
|
||||||
|
|
||||||
|
storage := NewREST(&webhook, issuer)
|
||||||
requestToken := "a token"
|
requestToken := "a token"
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
||||||
@ -137,9 +161,8 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
|
|||||||
},
|
},
|
||||||
Credential: &placeholderapi.LoginRequestCredential{
|
Credential: &placeholderapi.LoginRequestCredential{
|
||||||
ExpirationTimestamp: nil,
|
ExpirationTimestamp: nil,
|
||||||
Token: "snorlax",
|
ClientCertificateData: "test-cert",
|
||||||
ClientCertificateData: "",
|
ClientKeyData: "test-key",
|
||||||
ClientKeyData: "",
|
|
||||||
},
|
},
|
||||||
Message: "",
|
Message: "",
|
||||||
},
|
},
|
||||||
@ -147,11 +170,38 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
|
|||||||
require.Equal(t, requestToken, webhook.calledWithToken)
|
require.Equal(t, requestToken, webhook.calledWithToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateFailsWithValidTokenWhenCertIssuerFails(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
|
webhook := FakeToken{
|
||||||
|
returnResponse: &authenticator.Response{
|
||||||
|
User: &user.DefaultInfo{
|
||||||
|
Name: "test-user",
|
||||||
|
Groups: []string{"test-group-1", "test-group-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
returnUnauthenticated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
|
||||||
|
issuer.EXPECT().
|
||||||
|
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
Return(nil, nil, fmt.Errorf("some certificate authority error"))
|
||||||
|
|
||||||
|
storage := NewREST(&webhook, issuer)
|
||||||
|
requestToken := "a token"
|
||||||
|
|
||||||
|
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
||||||
|
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
|
||||||
|
require.Equal(t, requestToken, webhook.calledWithToken)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookDoesNotAuthenticateTheToken(t *testing.T) {
|
func TestCreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookDoesNotAuthenticateTheToken(t *testing.T) {
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnUnauthenticated: true,
|
returnUnauthenticated: true,
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
requestToken := "a token"
|
requestToken := "a token"
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
||||||
@ -164,7 +214,7 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails(t *testing.T)
|
|||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnErr: errors.New("some webhook error"),
|
returnErr: errors.New("some webhook error"),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
||||||
|
|
||||||
@ -175,7 +225,7 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookDoesNotReturnAnyUse
|
|||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: &authenticator.Response{},
|
returnResponse: &authenticator.Response{},
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
||||||
|
|
||||||
@ -190,7 +240,7 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsern
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
||||||
|
|
||||||
@ -198,10 +248,13 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsern
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
|
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
|
||||||
|
|
||||||
_, err := callCreate(ctx, storage, validLoginRequest())
|
_, err := callCreate(ctx, storage, validLoginRequest())
|
||||||
@ -212,7 +265,7 @@ func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
||||||
notALoginRequest := runtime.Unknown{}
|
notALoginRequest := runtime.Unknown{}
|
||||||
response, err := NewREST(&FakeToken{}).Create(
|
response, err := NewREST(&FakeToken{}, nil).Create(
|
||||||
genericapirequest.NewContext(),
|
genericapirequest.NewContext(),
|
||||||
¬ALoginRequest,
|
¬ALoginRequest,
|
||||||
rest.ValidateAllObjectFunc,
|
rest.ValidateAllObjectFunc,
|
||||||
@ -222,7 +275,7 @@ func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: placeholderapi.TokenLoginCredentialType,
|
Type: placeholderapi.TokenLoginCredentialType,
|
||||||
Token: nil,
|
Token: nil,
|
||||||
@ -233,7 +286,7 @@ func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTypeInRequestIsMissing(t *testing.T) {
|
func TestCreateFailsWhenTypeInRequestIsMissing(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: "",
|
Type: "",
|
||||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
||||||
@ -244,7 +297,7 @@ func TestCreateFailsWhenTypeInRequestIsMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTypeInRequestIsNotLegal(t *testing.T) {
|
func TestCreateFailsWhenTypeInRequestIsNotLegal(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: "this in an invalid type",
|
Type: "this in an invalid type",
|
||||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
||||||
@ -255,7 +308,7 @@ func TestCreateFailsWhenTypeInRequestIsNotLegal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: placeholderapi.TokenLoginCredentialType,
|
Type: placeholderapi.TokenLoginCredentialType,
|
||||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: ""},
|
Token: &placeholderapi.LoginRequestTokenCredential{Value: ""},
|
||||||
@ -266,7 +319,7 @@ func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenValidationFails(t *testing.T) {
|
func TestCreateFailsWhenValidationFails(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := storage.Create(
|
response, err := storage.Create(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
validLoginRequest(),
|
validLoginRequest(),
|
||||||
@ -279,10 +332,13 @@ func TestCreateFailsWhenValidationFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDoesNotAllowValidationFunctionToMutateRequest(t *testing.T) {
|
func TestCreateDoesNotAllowValidationFunctionToMutateRequest(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
requestToken := "a token"
|
requestToken := "a token"
|
||||||
response, err := storage.Create(
|
response, err := storage.Create(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@ -299,10 +355,14 @@ func TestCreateDoesNotAllowValidationFunctionToMutateRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken(t *testing.T) {
|
func TestCreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
|
||||||
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
validationFunctionWasCalled := false
|
validationFunctionWasCalled := false
|
||||||
var validationFunctionSawTokenValue string
|
var validationFunctionSawTokenValue string
|
||||||
response, err := storage.Create(
|
response, err := storage.Create(
|
||||||
@ -322,7 +382,7 @@ func TestCreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
||||||
response, err := NewREST(&FakeToken{}).Create(
|
response, err := NewREST(&FakeToken{}, nil).Create(
|
||||||
genericapirequest.NewContext(),
|
genericapirequest.NewContext(),
|
||||||
validLoginRequest(),
|
validLoginRequest(),
|
||||||
rest.ValidateAllObjectFunc,
|
rest.ValidateAllObjectFunc,
|
||||||
@ -335,13 +395,16 @@ func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t *testing.T) {
|
func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhookStartedRunningNotificationChan := make(chan bool)
|
webhookStartedRunningNotificationChan := make(chan bool)
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
timeout: time.Second * 2,
|
timeout: time.Second * 2,
|
||||||
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
c := make(chan bool)
|
c := make(chan bool)
|
||||||
@ -362,13 +425,16 @@ func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself(t *testing.T) {
|
func TestCreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhookStartedRunningNotificationChan := make(chan bool)
|
webhookStartedRunningNotificationChan := make(chan bool)
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
@ -7,11 +7,13 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
@ -44,18 +46,75 @@ func TestSuccessfulLoginRequest(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Note: If this assertion fails then your TMC token might have expired. Get a fresh one and try again.
|
||||||
require.Empty(t, response.Status.Message)
|
require.Empty(t, response.Status.Message)
|
||||||
|
|
||||||
require.Empty(t, response.Spec)
|
require.Empty(t, response.Spec)
|
||||||
require.NotNil(t, response.Status.Credential)
|
require.NotNil(t, response.Status.Credential)
|
||||||
require.NotEmpty(t, response.Status.Credential.Token)
|
require.Empty(t, response.Status.Credential.Token)
|
||||||
require.Empty(t, response.Status.Credential.ClientCertificateData)
|
require.NotEmpty(t, response.Status.Credential.ClientCertificateData)
|
||||||
require.Empty(t, response.Status.Credential.ClientKeyData)
|
require.NotEmpty(t, response.Status.Credential.ClientKeyData)
|
||||||
require.Nil(t, response.Status.Credential.ExpirationTimestamp)
|
require.Nil(t, response.Status.Credential.ExpirationTimestamp)
|
||||||
|
|
||||||
require.NotNil(t, response.Status.User)
|
require.NotNil(t, response.Status.User)
|
||||||
require.NotEmpty(t, response.Status.User.Name)
|
require.NotEmpty(t, response.Status.User.Name)
|
||||||
require.Contains(t, response.Status.User.Groups, "tmc:member")
|
require.Contains(t, response.Status.User.Groups, "tmc:member")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
const readonlyBindingName = "integration-test-user-readonly-role-binding"
|
||||||
|
|
||||||
|
adminClient := library.NewClientset(t)
|
||||||
|
_, err = adminClient.RbacV1().ClusterRoleBindings().Get(ctx, readonlyBindingName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
// "404 not found" errors are acceptable, but others would be unexpected
|
||||||
|
statusError, isStatus := err.(*errors.StatusError)
|
||||||
|
require.True(t, isStatus)
|
||||||
|
require.Equal(t, http.StatusNotFound, int(statusError.Status().Code))
|
||||||
|
|
||||||
|
// Create a ClusterRoleBinding for this user only if one is not already found (so you can run tests more than once)
|
||||||
|
bindUserToReadonly := rbacv1.ClusterRoleBinding{
|
||||||
|
TypeMeta: metav1.TypeMeta{},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: readonlyBindingName,
|
||||||
|
},
|
||||||
|
Subjects: []rbacv1.Subject{{
|
||||||
|
Kind: rbacv1.UserKind,
|
||||||
|
APIGroup: rbacv1.GroupName,
|
||||||
|
Name: response.Status.User.Name,
|
||||||
|
}},
|
||||||
|
RoleRef: rbacv1.RoleRef{
|
||||||
|
Kind: "ClusterRole",
|
||||||
|
APIGroup: rbacv1.GroupName,
|
||||||
|
Name: "view",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err = adminClient.RbacV1().ClusterRoleBindings().Create(ctx, &bindUserToReadonly, metav1.CreateOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err = adminClient.RbacV1().ClusterRoleBindings().Delete(ctx, readonlyBindingName, metav1.DeleteOptions{})
|
||||||
|
require.NoError(t, err, "Test failed to clean up after itself")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a client using the certificate from the LoginRequest
|
||||||
|
clientWithCert := library.NewClientsetWithConfig(
|
||||||
|
t,
|
||||||
|
library.NewClientConfigWithCertAndKey(
|
||||||
|
t,
|
||||||
|
response.Status.Credential.ClientCertificateData,
|
||||||
|
response.Status.Credential.ClientKeyData,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use the client which is authenticated as the TMC user to list namespaces
|
||||||
|
listNamespaceResponse, err := clientWithCert.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, listNamespaceResponse.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFailedLoginRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser(t *testing.T) {
|
func TestFailedLoginRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser(t *testing.T) {
|
||||||
@ -73,7 +132,7 @@ func TestFailedLoginRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTh
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T) {
|
func TestLoginRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T) {
|
||||||
_, err := makeRequest(t, v1alpha1.LoginRequestSpec{
|
response, err := makeRequest(t, v1alpha1.LoginRequestSpec{
|
||||||
Type: v1alpha1.TokenLoginCredentialType,
|
Type: v1alpha1.TokenLoginCredentialType,
|
||||||
Token: nil,
|
Token: nil,
|
||||||
})
|
})
|
||||||
@ -87,6 +146,9 @@ func TestLoginRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T) {
|
|||||||
require.Equal(t, metav1.CauseType("FieldValueRequired"), cause.Type)
|
require.Equal(t, metav1.CauseType("FieldValueRequired"), cause.Type)
|
||||||
require.Equal(t, "Required value: token must be supplied", cause.Message)
|
require.Equal(t, "Required value: token must be supplied", cause.Message)
|
||||||
require.Equal(t, "spec.token.value", cause.Field)
|
require.Equal(t, "spec.token.value", cause.Field)
|
||||||
|
|
||||||
|
require.Empty(t, response.Spec)
|
||||||
|
require.Nil(t, response.Status.Credential)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAPIResourceList(t *testing.T) {
|
func TestGetAPIResourceList(t *testing.T) {
|
||||||
@ -102,7 +164,7 @@ func TestGetAPIResourceList(t *testing.T) {
|
|||||||
expectedGroup := &metav1.APIGroup{
|
expectedGroup := &metav1.APIGroup{
|
||||||
Name: "placeholder.suzerain-io.github.io",
|
Name: "placeholder.suzerain-io.github.io",
|
||||||
Versions: []metav1.GroupVersionForDiscovery{
|
Versions: []metav1.GroupVersionForDiscovery{
|
||||||
metav1.GroupVersionForDiscovery{
|
{
|
||||||
GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1",
|
GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1",
|
||||||
Version: "v1alpha1",
|
Version: "v1alpha1",
|
||||||
},
|
},
|
||||||
@ -125,7 +187,7 @@ func TestGetAPIResourceList(t *testing.T) {
|
|||||||
},
|
},
|
||||||
GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1",
|
GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1",
|
||||||
APIResources: []metav1.APIResource{
|
APIResources: []metav1.APIResource{
|
||||||
metav1.APIResource{
|
{
|
||||||
Name: "loginrequests",
|
Name: "loginrequests",
|
||||||
Kind: "LoginRequest",
|
Kind: "LoginRequest",
|
||||||
SingularName: "", // TODO(akeesler): what should this be?
|
SingularName: "", // TODO(akeesler): what should this be?
|
||||||
@ -143,7 +205,7 @@ func TestGetAPIVersion(t *testing.T) {
|
|||||||
|
|
||||||
version, err := client.Discovery().ServerVersion()
|
version, err := client.Discovery().ServerVersion()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, version) // TODO(akeesler: what can we assert here?
|
require.NotNil(t, version) // TODO(akeesler): what can we assert here?
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGroup(name string, groups []*metav1.APIGroup) *metav1.APIGroup {
|
func findGroup(name string, groups []*metav1.APIGroup) *metav1.APIGroup {
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||||
|
|
||||||
placeholdernameclientset "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned"
|
placeholdernameclientset "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned"
|
||||||
)
|
)
|
||||||
@ -19,18 +20,42 @@ import (
|
|||||||
func NewClientConfig(t *testing.T) *rest.Config {
|
func NewClientConfig(t *testing.T) *rest.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newClientConfigWithOverrides(t, &clientcmd.ConfigOverrides{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientConfigWithCertAndKey(t *testing.T, cert, key string) *rest.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return newClientConfigWithOverrides(t, &clientcmd.ConfigOverrides{
|
||||||
|
AuthInfo: clientcmdapi.AuthInfo{
|
||||||
|
ClientCertificateData: []byte(cert),
|
||||||
|
ClientKeyData: []byte(key),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClientConfigWithOverrides(t *testing.T, overrides *clientcmd.ConfigOverrides) *rest.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
loader := clientcmd.NewDefaultClientConfigLoadingRules()
|
loader := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||||
clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loader, &clientcmd.ConfigOverrides{})
|
clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loader, overrides)
|
||||||
config, err := clientConfig.ClientConfig()
|
config, err := clientConfig.ClientConfig()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientset(t *testing.T) kubernetes.Interface {
|
func NewClientset(t *testing.T) kubernetes.Interface {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return kubernetes.NewForConfigOrDie(NewClientConfig(t))
|
return NewClientsetWithConfig(t, NewClientConfig(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientsetWithConfig(t *testing.T, config *rest.Config) kubernetes.Interface {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := kubernetes.NewForConfig(config)
|
||||||
|
require.NoError(t, err, "unexpected failure from kubernetes.NewForConfig()")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlaceholderNameClientset(t *testing.T) placeholdernameclientset.Interface {
|
func NewPlaceholderNameClientset(t *testing.T) placeholdernameclientset.Interface {
|
||||||
|
@ -9,5 +9,6 @@ SPDX-License-Identifier: Apache-2.0
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "github.com/golang/mock/mockgen"
|
||||||
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
|
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user