Compare commits

...

3 Commits

Author SHA1 Message Date
4fe54aabf0 Fixed some more rbac issues, v2022.03.15 2022-03-15 01:39:12 +09:00
c2272e3816 Fixed RBAC issues on 1.23.0 <= 2022-03-14 17:26:48 +09:00
3eea4a96a6 Proper log handling 2022-03-14 17:26:29 +09:00
9 changed files with 257 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.16-alpine AS build_deps
FROM golang:1.17-alpine AS build_deps
RUN apk add --no-cache git

View File

@@ -1,54 +1,59 @@
# ACME webhook example
# Introduction
First, [RTFM](https://cert-manager.io/docs/configuration/acme/dns01/).
The ACME issuer type supports an optional 'webhook' solver, which can be used
to implement custom DNS01 challenge solving logic.
Have you read it? If you haven't go read it. Cuz I'll keep everything short.
This is useful if you need to use cert-manager with a DNS provider that is not
officially supported in cert-manager core.
This is a dns01 solver for [FreeDNS](https://freedns.afraid.org/).
## Why not in core?
As the project & adoption has grown, there has been an influx of DNS provider
pull requests to our core codebase. As this number has grown, the test matrix
has become un-maintainable and so, it's not possible for us to certify that
providers work to a sufficient level.
By creating this 'interface' between cert-manager and DNS providers, we allow
users to quickly iterate and test out new integrations, and then packaging
those up themselves as 'extensions' to cert-manager.
We can also then provide a standardised 'testing framework', or set of
conformance tests, which allow us to validate the a DNS provider works as
expected.
## Creating your own webhook
Webhook's themselves are deployed as Kubernetes API services, in order to allow
administrators to restrict access to webhooks with Kubernetes RBAC.
This is important, as otherwise it'd be possible for anyone with access to your
webhook to complete ACME challenge validations and obtain certificates.
To make the set up of these webhook's easier, we provide a template repository
that can be used to get started quickly.
### Creating your own repository
### Running the test suite
All DNS providers **must** run the DNS01 provider conformance testing suite,
else they will have undetermined behaviour when used with cert-manager.
**It is essential that you configure and run the test suite when creating a
DNS01 webhook.**
An example Go test file has been provided in [main_test.go](https://github.com/jetstack/cert-manager-webhook-example/blob/master/main_test.go).
You can run the test suite with:
Pull requests welcome. I'm completely unfamiliar with golang. I did it by looking at
other webhook repos and this is the result.
## Install
```bash
$ TEST_ZONE_NAME=example.com. make test
$ cd deploy
$ helm show values freedns-webhook > my-values.yaml
$ edit my-values.yaml
$ helm install -n cert-manager [INSTALLATION_NAME] freedns-webhook/ -f my-values.yaml
```
The example file has a number of areas you must fill in and replace with your
own options in order for tests to pass.
## ClusterIssuer for Let's encrypt staging
```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: myemail@example.com
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: le-staging
solvers:
- dns01:
webhook:
groupName: acme.freedns.afraid.org
solverName: freedns-solver
config:
secretName: freedns-auth
```
## FreeDNS webhook settings
Normally if you haven't changed anything, the default namespace should be
`cert-manager`. It should be within the same namespace for the webhook when
you do `helm install webhook -n cert-manager`.
```yaml
apiVersion: v1
kind: Secret
metadata:
name: freedns-auth
namespace: cert-manager
data:
username: [YOUR_USERNAME_IN_BASE64]
password: [YOUR_PASSWORD_IN_BASE64]
type: Opaque
```
Additionally, the following names can be customized
* acme.freedns.afraid.org
* freedns-auth

View File

@@ -8,6 +8,45 @@ metadata:
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "freedns-webhook.fullname" . }}:secret-read
namespace: {{ .Release.Namespace }}
labels:
app: {{ include "freedns-webhook.name" . }}
chart: {{ include "freedns-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
rules:
- apiGroups:
- ''
resources:
- 'secrets'
verbs:
- 'get'
---
# Grant the webhook permission to read the secret
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "freedns-webhook.fullname" . }}:secret-read
namespace: {{ .Release.Namespace }}
labels:
app: {{ include "freedns-webhook.name" . }}
chart: {{ include "freedns-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "freedns-webhook.fullname" . }}:secret-read
subjects:
- apiGroup: ""
kind: ServiceAccount
name: {{ include "freedns-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}
---
# Grant the webhook permission to read the ConfigMap containing the Kubernetes
# apiserver's requestheader-ca-certificate.
# This ConfigMap is automatically created by the Kubernetes apiserver.
@@ -88,3 +127,42 @@ subjects:
kind: ServiceAccount
name: {{ .Values.certManager.serviceAccountName }}
namespace: {{ .Values.certManager.namespace }}
---
# Grant pod account permission to validate using our apiserver
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "freedns-webhook.fullname" . }}:flowcontrol
labels:
app: {{ include "freedns-webhook.name" . }}
chart: {{ include "freedns-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
rules:
- apiGroups:
- "flowcontrol.apiserver.k8s.io"
resources:
- 'prioritylevelconfigurations'
- 'flowschemas'
verbs:
- 'list'
- 'watch'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "freedns-webhook.fullname" . }}:flowcontrol
labels:
app: {{ include "freedns-webhook.name" . }}
chart: {{ include "freedns-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "freedns-webhook.fullname" . }}:flowcontrol
subjects:
- apiGroup: ""
kind: ServiceAccount
name: {{ include "freedns-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}

View File

@@ -1,20 +1,20 @@
# The GroupName here is used to identify your company or business unit that
# created this webhook.
# For freedns, this may be "acme.mycompany.com".
# For freedns, this may be "acme.freedns.afraid.org".
# This name will need to be referenced in each Issuer's `webhook` stanza to
# inform cert-manager of where to send ChallengePayload resources in order to
# solve the DNS01 challenge.
# This group name should be **unique**, hence using your own company's domain
# here is recommended.
groupName: acme.mycompany.com
groupName: acme.freedns.afraid.org
certManager:
namespace: cert-manager
serviceAccountName: cert-manager
image:
repository: mycompany/webhook-image
tag: latest
repository: penguinade/cert-manager-webhook-freedns
tag: 2022.03.15
pullPolicy: IfNotPresent
nameOverride: ""

View File

@@ -6,8 +6,10 @@ import (
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
logf "github.com/jetstack/cert-manager/pkg/logs"
"golang.org/x/net/html"
)
@@ -22,6 +24,7 @@ type FreeDNSOperations interface {
type FreeDNS struct {
AuthCookie *http.Cookie
DomainId string
LoggedOut bool
}
const URI_LOGIN = "https://freedns.afraid.org/zc.php?step=2"
@@ -32,7 +35,15 @@ const URI_SUBDOMAIN_EDIT = "https://freedns.afraid.org/subdomain/edit.php?data_i
const URI_LOGOUT = "https://freedns.afraid.org/logout/"
const URI_DELETE_RECORD = "https://freedns.afraid.org/subdomain/delete2.php?data_id[]=%s&submit=delete%%20selected"
// const URI_LOGIN string = "http://127.0.0.1:1234/"
func LogInfo(Mesg string) {
// fmt.Println(Mesg)
logf.V(logf.InfoLevel).Info(Mesg)
}
func LogDebug(Mesg string) {
// fmt.Println(Mesg)
logf.V(logf.DebugLevel).Info(Mesg)
}
func GetDomainFromZone(Zone string) string {
_segs := strings.Split(strings.TrimSuffix(Zone, "."), ".")
@@ -63,6 +74,8 @@ func _HttpRequest(method string, url string, PostData url.Values, ExCookie *http
return nil, "", err
}
req.Header.Set("User-Agent", "github.com/tgckpg/cert-manager-webhook-freedns (2022.03.15)")
if ExCookie != nil {
req.AddCookie(ExCookie)
}
@@ -102,6 +115,7 @@ func (dnsObj *FreeDNS) Login(Username string, Password string) error {
for _, cookie := range resp.Cookies() {
if cookie.Name == "dns_cookie" {
dnsObj.AuthCookie = cookie
dnsObj.LoggedOut = false
}
}
@@ -109,14 +123,16 @@ func (dnsObj *FreeDNS) Login(Username string, Password string) error {
}
func (dnsObj *FreeDNS) Logout() error {
if dnsObj.AuthCookie == nil {
if dnsObj.LoggedOut {
return nil
}
_, _, err := _HttpRequest("GET", URI_DOMAIN, nil, dnsObj.AuthCookie)
_, _, err := _HttpRequest("GET", URI_LOGOUT, nil, dnsObj.AuthCookie)
if err != nil {
return err
}
dnsObj.LoggedOut = true
return nil
}
@@ -149,7 +165,7 @@ loop:
break loop
case html.TextToken:
if inBold && strings.TrimSpace(htmlTokens.Token().Data) == DomainName {
fmt.Println("Found " + DomainName + ", looking for domain id")
LogInfo("Found " + DomainName + ", looking for domain id")
lookForA = true
}
// The [Manage] anchor is next to the bold tag
@@ -162,9 +178,9 @@ loop:
for {
attrKey, attrValue, moreAttr := htmlTokens.TagAttr()
_href := string(attrValue)
if string(attrKey) == "href" && strings.Contains(_href, "/subdomain/?limit=") {
if string(attrKey) == "href" && strings.HasPrefix(_href, "/subdomain/?limit=") {
dnsObj.DomainId = strings.TrimPrefix(_href, "/subdomain/?limit=")
fmt.Printf("Domain id for \"%s\" is %s\n", DomainName, dnsObj.DomainId)
LogDebug(fmt.Sprintf("Domain id for \"%s\" is %s\n", DomainName, dnsObj.DomainId))
break loop
}
if !moreAttr {
@@ -205,10 +221,15 @@ func (dnsObj *FreeDNS) AddRecord(RecordType string, Subdomain string, Address st
// Record already exists, treat this as success
if strings.Contains(respStr, "already have another already existent") {
fmt.Println("Record already exists")
LogInfo("Record already exists")
return nil
}
// Try get a sense of the problem
var errorMesgs []string
lookForNextEl := 0
lookForText := false
_strBuffer := ""
htmlTokens := html.NewTokenizer(strings.NewReader(respStr))
loop:
for {
@@ -217,10 +238,40 @@ func (dnsObj *FreeDNS) AddRecord(RecordType string, Subdomain string, Address st
case html.ErrorToken:
break loop
case html.TextToken:
_text := strings.TrimSpace(string(htmlTokens.Text()))
// Search for the "1 error" / "N errors" message
if strings.HasSuffix(_text, "error") || strings.HasSuffix(_text, "errors") {
_text = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(_text, "s"), "error"))
_n, _ := strconv.ParseInt(_text, 10, 8)
// + 1 because we are already inside a font tag
// The next closing </font> is ourself
lookForNextEl = int(_n) + 1
} else if lookForText {
_strBuffer = _strBuffer + _text
}
case html.StartTagToken:
_t, _ := htmlTokens.TagName()
tagName := string(_t)
if tagName == "font" && 0 < lookForNextEl {
lookForText = true
_strBuffer = ""
}
case html.EndTagToken:
_t, _ := htmlTokens.TagName()
tagName := string(_t)
if tagName == "font" && 0 < lookForNextEl {
lookForText = false
errorMesgs = append(errorMesgs, strings.TrimSpace(_strBuffer))
lookForNextEl--
}
}
}
if 0 < len(errorMesgs) {
return errors.New(strings.Join(errorMesgs, ", "))
}
return errors.New("Unknown error while submitting record")
}
@@ -229,8 +280,8 @@ func (dnsObj *FreeDNS) AddRecord(RecordType string, Subdomain string, Address st
return err
}
if strings.Contains(_Location.Path, "/zc.php") {
fmt.Println("Error on AddRecord: Cookie expired")
if strings.HasPrefix(_Location.Path, "/zc.php") {
LogDebug("Error on AddRecord: Cookie expired")
return errors.New("dns_cookie maybe expired")
}
@@ -278,7 +329,7 @@ func (dnsObj *FreeDNS) FindRecord(Subdomain string, RecordType string, Address s
CurrRecordType := ""
CurrRecordAddr := ""
CurrTagName := ""
lookForNextTD := 0
lookForNextEl := 0
htmlTokens := html.NewTokenizer(strings.NewReader(respStr))
loop:
@@ -288,13 +339,13 @@ loop:
case html.ErrorToken:
break loop
case html.TextToken:
if CurrTagName == "a" && lookForNextTD == 1 && CurrRecordAddr == "" {
if CurrTagName == "a" && lookForNextEl == 1 && CurrRecordAddr == "" {
CurrRecordAddr = strings.TrimSpace(string(htmlTokens.Text()))
} else if CurrTagName == "td" {
if lookForNextTD == 1 {
if lookForNextEl == 1 {
CurrRecordType = string(htmlTokens.Text())
lookForNextTD = 2
} else if lookForNextTD == 2 {
lookForNextEl = 2
} else if lookForNextEl == 2 {
_Addr := string(htmlTokens.Text())
if CurrRecordType == RecordType && CurrRecordAddr == Subdomain {
if _Addr == Address {
@@ -303,7 +354,7 @@ loop:
DeepSearchCandidates = append(DeepSearchCandidates, CurrRecordId)
}
}
lookForNextTD = 0
lookForNextEl = 0
}
}
/** Each record is displayed with the following structure
@@ -323,7 +374,7 @@ loop:
attrKey, attrValue, moreAttr := htmlTokens.TagAttr()
_href := string(attrValue)
if string(attrKey) == "href" && strings.Contains(_href, "edit.php?data_id=") {
lookForNextTD = 1
lookForNextEl = 1
CurrRecordAddr = ""
CurrRecordId = strings.TrimPrefix(_href, "edit.php?data_id=")
break
@@ -340,7 +391,7 @@ loop:
// Begin deep search for truncated records
htmlAddr := strings.ReplaceAll(html.EscapeString(Address), "&#34;", "&quot;")
for _, RecordId := range DeepSearchCandidates {
fmt.Println("Searching in " + RecordId)
LogDebug("Searching in " + RecordId)
_, respStr, err := _HttpRequest("GET", URI_SUBDOMAIN_EDIT+RecordId, nil, dnsObj.AuthCookie)
if err != nil {
continue

42
freedns/freedns_test.go Executable file
View File

@@ -0,0 +1,42 @@
package freedns
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
/*
func TestHttpRequest(t *testing.T) {
_HttpRequest("GET", "http://127.0.0.1:12345", nil, nil)
}
*/
func TestGetDomainFromZone(t *testing.T) {
assert.Equal(t, GetDomainFromZone("a.b.example.com"), "example.com")
assert.Equal(t, GetDomainFromZone("example.com"), "example.com")
}
func TestOperations(t *testing.T) {
freeDNS := FreeDNS{}
var UserName = os.Getenv("FREEDNS_USERNAME")
var Password = os.Getenv("FREEDNS_PASSWORD")
var SelectedDomain = os.Getenv("FREEDNS_DOMAIN")
require.NotEmpty(t, UserName, "Please set the env vars for FREEDNS_USERNAME")
require.NotEmpty(t, Password, "Please set the env vars for FREEDNS_PASSWORD")
require.NotEmpty(t, SelectedDomain, "Please set the env vars for FREEDNS_DOMAIN")
require.Nil(t, freeDNS.Login(UserName, Password))
require.Nil(t, freeDNS.SelectDomain(SelectedDomain))
require.Nil(t, freeDNS.AddRecord("TXT", "", "\"TEST\"", false, ""))
id, _ := freeDNS.FindRecord(SelectedDomain, "TXT", "\"TEST\"")
require.NotEmpty(t, id)
require.Nil(t, freeDNS.DeleteRecord(id))
require.Nil(t, freeDNS.Logout())
require.Equal(t, freeDNS.LoggedOut, true)
}

2
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/cert-manager/webhook-freedns
go 1.17
require (
github.com/jetstack/cert-manager v1.7.0
github.com/jetstack/cert-manager v1.7.1
github.com/miekg/dns v1.1.34
github.com/stretchr/testify v1.7.0
k8s.io/apiextensions-apiserver v0.23.1

2
go.sum
View File

@@ -305,6 +305,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jetstack/cert-manager v1.7.0 h1:XLLDmREJ8MlBg/Z1bySjHdyzT4yYTzGlbBsxqddJzxU=
github.com/jetstack/cert-manager v1.7.0/go.mod h1:xj0TPp31HE0Jub5mNOnF3Fp3XvhIsiP+tsPZVOmU/Qs=
github.com/jetstack/cert-manager v1.7.1 h1:qIIP0RN5FzBChJLJ3uGCGJmdAAonwDMdcsJExATa64I=
github.com/jetstack/cert-manager v1.7.1/go.mod h1:xj0TPp31HE0Jub5mNOnF3Fp3XvhIsiP+tsPZVOmU/Qs=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=

12
main.go
View File

@@ -121,7 +121,7 @@ func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
_zone = strings.TrimRight(_zone, ".")
_key := "\"" + ch.Key + "\""
fmt.Println("ADD", _zone, _key)
freedns.LogInfo(fmt.Sprintf("ADD %s %s", _zone, _key))
err = dnsObj.AddRecord("TXT", _zone, _key, false, "")
if err != nil {
@@ -140,11 +140,17 @@ func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
// concurrently.
func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
if c.freedns.AuthCookie == nil {
return nil
}
dnsObj := c.freedns
_addr := strings.TrimRight(ch.ResolvedFQDN, ".")
_key := "\"" + ch.Key + "\""
_id, err := c.freedns.FindRecord(_addr, "TXT", _key)
fmt.Println("DEL", _addr)
freedns.LogInfo(fmt.Sprintf("DEL %s %s", _addr, _key))
if _id != "" {
err = c.freedns.DeleteRecord(_id)
@@ -153,7 +159,7 @@ func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
}
}
return c.freedns.Logout()
return dnsObj.Logout()
}
// Initialize will be called when the webhook first starts.