18 | REVERSE_PROXY_HTTP=127.0.0.1:8080
19 | #REVERSE_PROXY_HTTPS=127.0.0.1:8433
20 | #REVERSE_PROXY_DNS=127.0.0.1:8053
21 |
22 |
--------------------------------------------------------------------------------
/examples/zone_file.txt:
--------------------------------------------------------------------------------
1 | helloworld.knary.tld. 300 IN A 127.0.0.1
2 | sample.knary.tld. 300 IN TXT "A custom response here"
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/sudosammy/knary/v3
2 |
3 | go 1.24.0
4 |
5 | toolchain go1.24.4
6 |
7 | require (
8 | github.com/blang/semver/v4 v4.0.0
9 | github.com/fatih/color v1.18.0
10 | github.com/go-acme/lego/v4 v4.25.1
11 | github.com/joho/godotenv v1.5.1
12 | github.com/miekg/dns v1.1.67
13 | github.com/radovskyb/watcher v1.0.7
14 | golang.org/x/net v0.42.0
15 | )
16 |
17 | require (
18 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
19 | github.com/go-jose/go-jose/v4 v4.1.1 // indirect
20 | github.com/mattn/go-colorable v0.1.14 // indirect
21 | github.com/mattn/go-isatty v0.0.20 // indirect
22 | golang.org/x/crypto v0.40.0 // indirect
23 | golang.org/x/mod v0.26.0 // indirect
24 | golang.org/x/sync v0.16.0 // indirect
25 | golang.org/x/sys v0.34.0 // indirect
26 | golang.org/x/text v0.27.0 // indirect
27 | golang.org/x/tools v0.35.0 // indirect
28 | )
29 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
2 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
3 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
4 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
5 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
8 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
9 | github.com/go-acme/lego/v4 v4.25.1 h1:AYPUM7quPN/g2PcjjWw8sAMz3eV+Z8UWkr1kitDOyVA=
10 | github.com/go-acme/lego/v4 v4.25.1/go.mod h1:OORYyVNZPaNdIdVYCGSBNRNZDIjhQbPuFxwGDgWj/yM=
11 | github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
12 | github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
13 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
14 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
15 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
16 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
17 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
18 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
19 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
20 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
21 | github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0=
22 | github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
23 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
24 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
25 | github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
26 | github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
27 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
28 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
29 | golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
30 | golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
31 | golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
32 | golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
33 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
34 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
35 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
36 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
37 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
38 | golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
39 | golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
40 | golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
41 | golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
42 | golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
43 | golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
45 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
46 |
--------------------------------------------------------------------------------
/libknary/analytics.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "bytes"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "encoding/json"
8 | "net/http"
9 | "os"
10 | "runtime"
11 | "strconv"
12 | "time"
13 | )
14 |
15 | /*
16 | This function collects basic telemetry to track knary usage.
17 | It does NOT collect anything that could be tied back to you easily; however, does take a SHA256 hash of your knary domain name.
18 | If you have any thoughts about knary you can contact me on Twitter: @sudosammy or GitHub: https://github.com/sudosammy/knary
19 |
20 | You can make the following variable an empty string to sinkhole analytics.
21 | */
22 | var trackingDomain = "https://knary.sam.ooo"
23 |
24 | type features struct {
25 | DEBUG bool `json:"debug"`
26 | DNS bool `json:"dns"`
27 | DNS_SUBDOMAIN bool `json:"dns_subdomain"` // True/False
28 | HTTP bool `json:"http"`
29 | HTTP_FULL bool `json:"full_http_request"`
30 | BURP bool `json:"burp"`
31 | REV_PROXY bool `json:"reverse_proxy"`
32 | ALLOW int `json:"allowlist"` // Count of items in
33 | ALLOW_STRICT bool `json:"allowlist_strict"`
34 | DENY int `json:"denylist"` // Count of items in
35 | LE bool `json:"lets_encrypt"`
36 | TLS bool `json:"tls_certs"`
37 | LOGS bool `json:"logs"`
38 | ZONE_FILE int `json:"zone_file"` // Count of items in
39 | DENYLIST_ALERTING bool `json:"no_denylist_alert"`
40 | NO_HEARTBEAT bool `json:"no_heartbeat"`
41 | NO_UPDATES bool `json:"no_update_alert"`
42 | NO_CERT_EXPIRY bool `json:"no_cert_expiry_alert"`
43 | }
44 |
45 | type webhooks struct {
46 | SLACK bool `json:"slack"`
47 | DISCORD bool `json:"discord"`
48 | PUSHOVER bool `json:"pushover"`
49 | TEAMS bool `json:"teams"`
50 | LARK bool `json:"lark"`
51 | TELEGRAM bool `json:"telegram"`
52 | }
53 |
54 | type analy struct {
55 | ID string `json:"id"`
56 | Timestamp string `json:"timestamp"`
57 | basicInfo `json:"basic"`
58 | features `json:"features"`
59 | webhooks `json:"webhooks"`
60 | }
61 |
62 | type basicInfo struct {
63 | OS string `json:"os"`
64 | Uptime int `json:"uptime"`
65 | Version string `json:"version"`
66 | Offset int `json:"offset"`
67 | Timezone string `json:"tz"`
68 | }
69 |
70 | var day = 0
71 |
72 | func UsageStats(version string) bool {
73 | if os.Getenv("CANARY_DOMAIN") == "" || trackingDomain == "" {
74 | return false
75 | }
76 |
77 | knaryID := sha256.New()
78 | _, _ = knaryID.Write([]byte(os.Getenv("CANARY_DOMAIN")))
79 | anonKnaryID := hex.EncodeToString(knaryID.Sum(nil))
80 |
81 | ts := time.Now().UTC() // UTC timestamp
82 | utcTimestamp := ts.Format("2006-01-02 15:04")
83 | tz, offset := time.Now().Zone() // local timezone
84 |
85 | day++ // keep track of uptime
86 | debug, _ := strconv.ParseBool(os.Getenv("DEBUG"))
87 | dnsKnary, _ := strconv.ParseBool(os.Getenv("DNS"))
88 | httpKnary, _ := strconv.ParseBool(os.Getenv("HTTP"))
89 | fullHttp, _ := strconv.ParseBool(os.Getenv("FULL_HTTP_REQUEST"))
90 | allowStrict, _ := strconv.ParseBool(os.Getenv("ALLOWLIST_STRICT"))
91 | denylistAlertingInverted, _ := strconv.ParseBool(os.Getenv("DENYLIST_ALERTING"))
92 | denylistAlerting := !denylistAlertingInverted
93 | heartbeat, _ := strconv.ParseBool(os.Getenv("NO_HEARTBEAT_ALERT"))
94 | checkUpdates, _ := strconv.ParseBool(os.Getenv("NO_UPDATES_ALERT"))
95 | checkCertExpiry, _ := strconv.ParseBool(os.Getenv("NO_CERT_EXPIRY_ALERT"))
96 |
97 | dnsSubdomain := false
98 | if len(os.Getenv("DNS_SUBDOMAIN")) > 0 {
99 | dnsSubdomain = true
100 | }
101 |
102 | burp := false
103 | if len(os.Getenv("BURP_DOMAIN")) > 0 {
104 | burp = true
105 | }
106 |
107 | revProxy := false
108 | if len(os.Getenv("REVERSE_PROXY_DOMAIN")) > 0 {
109 | revProxy = true
110 | }
111 |
112 | letsEnc := false
113 | if len(os.Getenv("LETS_ENCRYPT")) > 0 {
114 | letsEnc = true
115 | }
116 |
117 | tlsCerts := false
118 | if len(os.Getenv("TLS_CRT")) > 0 {
119 | tlsCerts = true
120 | }
121 |
122 | logFile := false
123 | if len(os.Getenv("LOG_FILE")) > 0 {
124 | logFile = true
125 | }
126 |
127 | // webhooks
128 | slack := false
129 | if len(os.Getenv("SLACK_WEBHOOK")) > 0 {
130 | slack = true
131 | }
132 |
133 | discord := false
134 | if len(os.Getenv("DISCORD_WEBHOOK")) > 0 {
135 | discord = true
136 | }
137 |
138 | pushover := false
139 | if len(os.Getenv("PUSHOVER_TOKEN")) > 0 {
140 | pushover = true
141 | }
142 |
143 | teams := false
144 | if len(os.Getenv("TEAMS_WEBHOOK")) > 0 {
145 | teams = true
146 | }
147 |
148 | lark := false
149 | if len(os.Getenv("LARK_WEBHOOK")) > 0 {
150 | lark = true
151 | }
152 |
153 | telegram := false
154 | if len(os.Getenv("TELEGRAM_CHATID")) > 0 {
155 | telegram = true
156 | }
157 |
158 | jsonValues, err := json.Marshal(&analy{
159 | anonKnaryID,
160 | utcTimestamp,
161 | basicInfo{
162 | runtime.GOOS,
163 | day,
164 | version,
165 | (offset / 60 / 60),
166 | tz,
167 | },
168 | features{
169 | debug,
170 | dnsKnary,
171 | dnsSubdomain,
172 | httpKnary,
173 | fullHttp,
174 | burp,
175 | revProxy,
176 | allowCount,
177 | allowStrict,
178 | denyCount,
179 | letsEnc,
180 | tlsCerts,
181 | logFile,
182 | zoneCounter,
183 | denylistAlerting,
184 | heartbeat,
185 | checkUpdates,
186 | checkCertExpiry,
187 | },
188 | webhooks{
189 | slack,
190 | discord,
191 | pushover,
192 | teams,
193 | lark,
194 | telegram,
195 | },
196 | })
197 |
198 | if err != nil {
199 | if os.Getenv("DEBUG") == "true" {
200 | Printy(err.Error(), 3)
201 | }
202 | return false
203 | }
204 |
205 | c := &http.Client{
206 | Timeout: 10 * time.Second,
207 | }
208 | _, err = c.Post(trackingDomain, "application/json", bytes.NewBuffer(jsonValues))
209 |
210 | if err != nil {
211 | if os.Getenv("DEBUG") == "true" {
212 | Printy(err.Error(), 3)
213 | }
214 | return false
215 | }
216 |
217 | return true
218 | }
219 |
--------------------------------------------------------------------------------
/libknary/certbot.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "crypto"
5 | "errors"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | "strconv"
10 | "time"
11 |
12 | "github.com/go-acme/lego/v4/certcrypto"
13 | "github.com/go-acme/lego/v4/certificate"
14 | "github.com/go-acme/lego/v4/challenge/dns01"
15 | "github.com/go-acme/lego/v4/lego"
16 | cmd "github.com/sudosammy/knary/v3/libknary/lego"
17 | )
18 |
19 | // Config is used to configure the creation of the DNSProvider.
20 | type Config struct {
21 | TTL int
22 | PropagationTimeout time.Duration
23 | PollingInterval time.Duration
24 | }
25 |
26 | // NewDefaultConfig returns a default configuration for the DNSProvider.
27 | func NewDefaultConfig() *Config {
28 | var confTTL int
29 | var confTimeout time.Duration
30 | var confPoll time.Duration
31 |
32 | if value, ok := os.LookupEnv("CERTBOT_TTL"); ok {
33 | confTTL, _ = strconv.Atoi(value)
34 | } else {
35 | confTTL = 120
36 | }
37 |
38 | if value, ok := os.LookupEnv("CERTBOT_PROPAGATION_TIMEOUT"); ok {
39 | timeVal, _ := strconv.Atoi(value)
40 | confTimeout = time.Duration(timeVal) * time.Second
41 | } else {
42 | confTimeout = 60 * time.Second
43 | }
44 |
45 | if value, ok := os.LookupEnv("CERTBOT_POLLING_INTERVAL"); ok {
46 | timeVal, _ := strconv.Atoi(value)
47 | confPoll = time.Duration(timeVal) * time.Second
48 | } else {
49 | confPoll = 2 * time.Second
50 | }
51 |
52 | return &Config{
53 | TTL: confTTL,
54 | PropagationTimeout: confTimeout,
55 | PollingInterval: confPoll,
56 | }
57 | }
58 |
59 | // DNSProvider implements the challenge.Provider interface.
60 | type DNSProvider struct {
61 | config *Config
62 | }
63 |
64 | func NewDNSProvider() (*DNSProvider, error) {
65 | config := NewDefaultConfig()
66 | return NewDNSProviderConfig(config)
67 | }
68 |
69 | func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
70 | if config == nil {
71 | return nil, errors.New("There was an error getting the configuration for Lets Encrypt")
72 | }
73 |
74 | return &DNSProvider{
75 | config: config,
76 | }, nil
77 | }
78 |
79 | // Present creates a TXT record to fulfill the dns-01 challenge.
80 | func (d *DNSProvider) Present(domain, token, keyAuth string) error {
81 | fqdn, value := dns01.GetRecord(domain, keyAuth)
82 | err := addZone(fqdn, d.config.TTL, "TXT", value)
83 | return err
84 | }
85 |
86 | // CleanUp removes the TXT record matching the specified parameters.
87 | func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
88 | fqdn, _ := dns01.GetRecord(domain, keyAuth)
89 | remZone(fqdn)
90 | return nil
91 | }
92 |
93 | func StartLetsEncrypt() {
94 | // check if folder structure is correct
95 | cmd.CreateFolderStructure()
96 |
97 | myUser := loadMyUser()
98 | config := lego.NewConfig(myUser)
99 |
100 | if os.Getenv("LE_ENV") == "staging" {
101 | config.CADirURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
102 |
103 | } else if os.Getenv("LE_ENV") == "dev" {
104 | config.CADirURL = "http://127.0.0.1:4001/directory"
105 | }
106 |
107 | // A client facilitates communication with the CA server.
108 | client, err := lego.NewClient(config)
109 | if err != nil {
110 | logger("ERROR", err.Error())
111 | GiveHead(2)
112 | log.Fatal(err)
113 | }
114 |
115 | knaryDNS, err := NewDNSProvider()
116 | if err != nil {
117 | logger("ERROR", err.Error())
118 | GiveHead(2)
119 | log.Fatal(err)
120 | }
121 |
122 | if os.Getenv("DNS_RESOLVER") != "" {
123 | client.Challenge.SetDNS01Provider(knaryDNS, dns01.AddRecursiveNameservers([]string{os.Getenv("DNS_RESOLVER")}))
124 | } else {
125 | client.Challenge.SetDNS01Provider(knaryDNS)
126 | }
127 |
128 | // if we're an existing user, loadMyUser would have populated cmd.Account with our Registration details
129 | currentReg, err := client.Registration.QueryRegistration()
130 |
131 | if err == nil && currentReg.Body.Status != "valid" {
132 | Printy("Found the Let's Encrypt user, but apparently the registration is not valid. We'll try re-registering...", 2)
133 |
134 | myUser.Registration = registerAccount(client)
135 |
136 | // save these registration details to disk
137 | accountStorage := cmd.NewAccountsStorage()
138 | if err := accountStorage.Save(myUser); err != nil {
139 | logger("ERROR", err.Error())
140 | GiveHead(2)
141 | log.Fatal(err)
142 | }
143 |
144 | } else if err == nil && currentReg.Body.Status == "valid" {
145 | myUser.Registration = currentReg
146 |
147 | } else {
148 | myUser.Registration = registerAccount(client)
149 |
150 | // save these registration details to disk
151 | accountStorage := cmd.NewAccountsStorage()
152 | if err := accountStorage.Save(myUser); err != nil {
153 | logger("ERROR", err.Error())
154 | GiveHead(2)
155 | log.Fatal(err)
156 | }
157 | }
158 |
159 | certsStorage := cmd.NewCertificatesStorage()
160 | firstDomain := GetFirstDomain()
161 |
162 | // should only request certs if currently none exist
163 | if fileExists(certsStorage.GetFileName("*."+firstDomain, ".key")) &&
164 | fileExists(certsStorage.GetFileName("*."+firstDomain, ".crt")) {
165 |
166 | if os.Getenv("DEBUG") == "true" {
167 | Printy("TLS private key found: "+certsStorage.GetFileName("*."+firstDomain, ".key"), 3)
168 | Printy("TLS certificate found: "+certsStorage.GetFileName("*."+firstDomain, ".crt"), 3)
169 | }
170 |
171 | // Set TLS_CRT and TLS_KEY to our LE generated certs
172 | os.Setenv("TLS_CRT", filepath.Join(cmd.GetCertPath(), cmd.SanitizedDomain("*."+firstDomain)+".crt"))
173 | os.Setenv("TLS_KEY", filepath.Join(cmd.GetCertPath(), cmd.SanitizedDomain("*."+firstDomain)+".key"))
174 |
175 | return
176 | }
177 |
178 | if os.Getenv("DEBUG") == "true" {
179 | Printy("No existing certificates found at:", 3)
180 | Printy(certsStorage.GetFileName("*."+firstDomain, ".key"), 2)
181 | Printy(certsStorage.GetFileName("*."+firstDomain, ".crt"), 2)
182 | Printy("Let's Encrypt ourselves some new ones!", 3)
183 | }
184 |
185 | request := certificate.ObtainRequest{
186 | Domains: getDomainsForCert(),
187 | Bundle: true,
188 | }
189 | certificates, err := client.Certificate.Obtain(request)
190 | if err != nil {
191 | logger("ERROR", err.Error())
192 | GiveHead(2)
193 | log.Fatal(err)
194 | }
195 |
196 | certsStorage.SaveResource(certificates)
197 |
198 | // Set TLS_CRT and TLS_KEY to our LE generated certs
199 | os.Setenv("TLS_CRT", filepath.Join(cmd.GetCertPath(), cmd.SanitizedDomain("*."+firstDomain)+".crt"))
200 | os.Setenv("TLS_KEY", filepath.Join(cmd.GetCertPath(), cmd.SanitizedDomain("*."+firstDomain)+".key"))
201 | }
202 |
203 | func renewError(msg string) {
204 | go sendMsg(":warning: " + msg)
205 | go sendMsg(":warning: knary is shutting down because of this error :(")
206 | logger("ERROR", msg)
207 | GiveHead(2)
208 | log.Fatal(msg)
209 | }
210 |
211 | func renewLetsEncrypt() {
212 | Printy("Attempting Let's Encrypt renewal", 1)
213 | logger("INFO", "Attempting Let's Encrypt certificate renewal.")
214 | go sendMsg(":lock: Attempting renewal of the Let's Encrypt certificate. I'll let you know how I go.")
215 |
216 | myUser := loadMyUser()
217 | config := lego.NewConfig(myUser)
218 |
219 | if os.Getenv("LE_ENV") == "staging" {
220 | config.CADirURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
221 |
222 | } else if os.Getenv("LE_ENV") == "dev" {
223 | config.CADirURL = "http://127.0.0.1:4001/directory"
224 | }
225 |
226 | client, err := lego.NewClient(config)
227 | if err != nil {
228 | renewError(err.Error())
229 | }
230 |
231 | knaryDNS, err := NewDNSProvider()
232 | if err != nil {
233 | renewError(err.Error())
234 | }
235 | client.Challenge.SetDNS01Provider(knaryDNS)
236 |
237 | //certDomains := getDomainsForCert()
238 | certsStorage := cmd.NewCertificatesStorage()
239 |
240 | var privateKey crypto.PrivateKey
241 |
242 | keyBytes, errR := certsStorage.ReadFile("*."+GetFirstDomain(), ".key")
243 | if errR != nil {
244 | renewError(errR.Error())
245 | }
246 |
247 | privateKey, errR = certcrypto.ParsePEMPrivateKey(keyBytes)
248 | if errR != nil {
249 | renewError(errR.Error())
250 | }
251 |
252 | pemBundle, errR := certsStorage.ReadFile("*."+GetFirstDomain(), ".pem")
253 | if errR != nil {
254 | renewError(errR.Error())
255 | }
256 |
257 | certificates, errR := certcrypto.ParsePEMBundle(pemBundle)
258 | if errR != nil {
259 | renewError(errR.Error())
260 | }
261 |
262 | x509Cert := certificates[0]
263 | if x509Cert.IsCA {
264 | renewError("Certificate bundle starts with a CA certificate")
265 | }
266 |
267 | query := certificate.ObtainRequest{
268 | Domains: certcrypto.ExtractDomains(x509Cert),
269 | Bundle: true,
270 | PrivateKey: privateKey,
271 | MustStaple: false,
272 | }
273 | certRes, errR := client.Certificate.Obtain(query)
274 | if errR != nil {
275 | renewError(errR.Error())
276 | }
277 |
278 | // move old certificates to archive folder
279 | if os.Getenv("DEBUG") == "true" {
280 | Printy("Archiving old certificates", 3)
281 | }
282 | err = certsStorage.MoveToArchive("*." + GetFirstDomain())
283 | if err != nil {
284 | msg := "There was an error moving the old certificates to the archive folder. Did you delete the folder? I'll overwrite the old certificates instead. See the log for more information."
285 | go sendMsg(":warning: " + msg)
286 | Printy(msg, 2)
287 | logger("WARNING", "Could not move certificates to archive: "+err.Error())
288 | }
289 |
290 | certsStorage.SaveResource(certRes)
291 | msg := "Certificate successfully renewed!"
292 | go sendMsg(":lock: " + msg)
293 | logger("INFO", msg)
294 | Printy(msg, 3)
295 | }
296 |
--------------------------------------------------------------------------------
/libknary/certutil.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "log"
5 | "os"
6 | "path/filepath"
7 | "strconv"
8 | "strings"
9 | "time"
10 |
11 | "github.com/go-acme/lego/v4/certcrypto"
12 | "github.com/go-acme/lego/v4/lego"
13 | "github.com/go-acme/lego/v4/registration"
14 | cmd "github.com/sudosammy/knary/v3/libknary/lego"
15 | )
16 |
17 | // create domain list for certificates
18 | func getDomainsForCert() []string {
19 | var domainArray []string
20 | var numDomains = 0
21 |
22 | for _, cdomain := range GetDomains() {
23 | domainArray = append(domainArray, "*."+cdomain)
24 | numDomains++
25 |
26 | // add root domain
27 | domainArray = append(domainArray, cdomain)
28 | numDomains++
29 |
30 | if os.Getenv("DNS_SUBDOMAIN") != "" {
31 | domainArray = append(domainArray, "*."+os.Getenv("DNS_SUBDOMAIN")+"."+cdomain)
32 | numDomains++
33 | }
34 | }
35 |
36 | if os.Getenv("BURP_DOMAIN") != "" {
37 | domainArray = append(domainArray, "*."+os.Getenv("BURP_DOMAIN"))
38 | numDomains++
39 |
40 | // add root domain
41 | domainArray = append(domainArray, os.Getenv("BURP_DOMAIN"))
42 | numDomains++
43 | }
44 |
45 | if os.Getenv("REVERSE_PROXY_DOMAIN") != "" {
46 | domainArray = append(domainArray, "*."+os.Getenv("REVERSE_PROXY_DOMAIN"))
47 | numDomains++
48 |
49 | // add root domain
50 | domainArray = append(domainArray, os.Getenv("REVERSE_PROXY_DOMAIN"))
51 | numDomains++
52 | }
53 |
54 | if os.Getenv("DEBUG") == "true" {
55 | Printy("Domains for SAN certificate: "+strconv.Itoa(numDomains), 3)
56 | }
57 |
58 | if numDomains > 100 {
59 | msg := "Too many domains! Let's Encrypt only supports SAN certificates containing 100 domains & subdomains. Your configuration currently has: " + strconv.Itoa(numDomains) + ". This may be due to configuring DNS_SUBDOMAIN which will double the number of SAN entries per CANARY_DOMAIN."
60 | logger("ERROR", msg)
61 | GiveHead(2)
62 | log.Fatal(msg)
63 | }
64 | return domainArray
65 | }
66 |
67 | func loadMyUser() *cmd.Account {
68 | accountStorage := cmd.NewAccountsStorage()
69 |
70 | privateKey := accountStorage.GetPrivateKey(certcrypto.EC384)
71 | //privateKey := accountStorage.GetPrivateKey(certcrypto.RSA2048)
72 |
73 | var account *cmd.Account
74 | if accountStorage.ExistsAccountFilePath() {
75 | account = accountStorage.LoadAccount(privateKey)
76 | } else {
77 | account = &cmd.Account{Email: accountStorage.GetUserID(), Key: privateKey}
78 | }
79 |
80 | return account
81 | }
82 |
83 | func registerAccount(client *lego.Client) *registration.Resource {
84 | // cmd.Account will just have our email address + private key in it, so we create new user
85 | reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
86 | if err != nil {
87 | logger("ERROR", err.Error())
88 | GiveHead(2)
89 | log.Fatal(err)
90 | }
91 | return reg
92 | }
93 |
94 | func needRenewal(days int) (bool, int) {
95 | certName := strings.TrimSuffix(filepath.Base(os.Getenv("TLS_CRT")), filepath.Ext(os.Getenv("TLS_CRT")))
96 | certExt := filepath.Ext(os.Getenv("TLS_CRT"))
97 |
98 | certsStorage := cmd.NewCertificatesStorage()
99 | certificates, err := certsStorage.ReadCertificate(certName, certExt)
100 | if err != nil {
101 | logger("ERROR", err.Error())
102 | GiveHead(2)
103 | log.Fatal(err)
104 | }
105 |
106 | x509Cert := certificates[0]
107 | // if x509Cert.IsCA {
108 | // Printy("Domain certificate bundle starts with a CA certificate.", 2)
109 | // logger("ERROR", "Cannot check for certificate expiry due to the domains certificate bundle (.crt) starts with a CA certificate.")
110 | // return false, 0
111 | // }
112 |
113 | notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
114 |
115 | if days >= 0 && notAfter > days {
116 | return false, notAfter
117 | }
118 | return true, notAfter
119 | }
120 |
--------------------------------------------------------------------------------
/libknary/dns.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "os"
8 | "strings"
9 | "sync"
10 |
11 | "github.com/miekg/dns"
12 | )
13 |
14 | func AcceptDNS(wg *sync.WaitGroup) {
15 | // start DNS server
16 | server := &dns.Server{Addr: os.Getenv("BIND_ADDR") + ":53", Net: "udp"}
17 | err := server.ListenAndServe()
18 |
19 | if err != nil {
20 | GiveHead(2)
21 | log.Fatal(err)
22 | }
23 |
24 | defer server.Shutdown()
25 | wg.Done()
26 | }
27 |
28 | // DNS is specified in RFC 1034 / RFC 1035
29 | // +---------------------+
30 | // | Header |
31 | // +---------------------+
32 | // | Question | the question for the name server
33 | // +---------------------+
34 | // | Answer | RRs answering the question
35 | // +---------------------+
36 | // | Authority | RRs pointing toward an authority
37 | // +---------------------+
38 | // | Additional | RRs holding additional information
39 | // +---------------------+
40 | //
41 | // DNS Header
42 | // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
43 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
44 | // | ID |
45 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
46 | // |QR| Opcode |AA|TC|RD|RA| Z | RCODE |
47 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
48 | // | QDCOUNT |
49 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
50 | // | ANCOUNT |
51 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
52 | // | NSCOUNT |
53 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
54 | // | ARCOUNT |
55 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
56 |
57 | func HandleDNS(w dns.ResponseWriter, r *dns.Msg, EXT_IP string) {
58 | // many thanks to the original author of this function
59 | m := new(dns.Msg)
60 | m.SetReply(r)
61 | m.Compress = false
62 | m.Authoritative = true
63 | parseDNS(m, w.RemoteAddr().String(), EXT_IP)
64 | w.WriteMsg(m)
65 | }
66 |
67 | func infoLog(ipaddr string, reverse string, name string) {
68 | // only log informationals if DEBUG is true
69 | if os.Getenv("DEBUG") == "true" {
70 | logger("INFO", ipaddr+" - "+reverse+" - "+name)
71 | }
72 | }
73 |
74 | func goSendMsg(ipaddr, reverse, name, record string) bool {
75 | if os.Getenv("DNS_SUBDOMAIN") != "" {
76 | found := false
77 | for _, cdomain := range GetDomains() {
78 | if stringContains(name, os.Getenv("DNS_SUBDOMAIN")+"."+cdomain) {
79 | // disregard unless subdomain we want to report on
80 | found = true
81 | }
82 | }
83 | if !found {
84 | return false
85 | }
86 | }
87 |
88 | if os.Getenv("DEBUG") == "true" {
89 | Printy("Got "+record+" question for: "+name, 3)
90 | }
91 |
92 | if inBlacklist(name, ipaddr) {
93 | return false // we check denylist first for consistent 'order of precedence' with the HTTP allow/denylist checking
94 | }
95 |
96 | if !inAllowlist(name, ipaddr) {
97 | return false
98 | }
99 |
100 | if reverse == "" {
101 | go sendMsg("DNS (" + record + "): " + name +
102 | "```" +
103 | "From: " + ipaddr +
104 | "```")
105 | infoLog(ipaddr, reverse, name)
106 |
107 | } else {
108 | go sendMsg("DNS (" + record + "): " + name +
109 | "```" +
110 | "From: " + ipaddr + "\n" +
111 | "PTR: " + reverse +
112 | "```")
113 | infoLog(ipaddr, reverse, name)
114 | }
115 | return true
116 | }
117 |
118 | func parseDNS(m *dns.Msg, ipaddr string, EXT_IP string) {
119 | // for each DNS question to our nameserver
120 | // there can be multiple questions in the question section of a single request
121 | for _, q := range m.Question {
122 | // search zone file and append response if found
123 | zoneResponse, foundInZone := inZone(q.Name, q.Qtype)
124 | if foundInZone {
125 | for _, element := range zoneResponse {
126 | m.Answer = append(m.Answer, element)
127 | }
128 | }
129 |
130 | // catch requests to pass through to Collaborator / reverse proxy
131 | if os.Getenv("BURP_DOMAIN") != "" || os.Getenv("REVERSE_PROXY_DOMAIN") != "" {
132 | // for burp config
133 | if os.Getenv("BURP_DOMAIN") != "" && strings.HasSuffix(strings.ToLower(q.Name), strings.ToLower(os.Getenv("BURP_DOMAIN"))+".") {
134 | // to support our container friends - let the player choose the IP Burp is bound to
135 | burpIP := ""
136 | if os.Getenv("BURP_INT_IP") != "" {
137 | burpIP = os.Getenv("BURP_INT_IP")
138 | } else {
139 | burpIP = "127.0.0.1"
140 | }
141 |
142 | // https://github.com/sudosammy/knary/issues/43
143 | //ipaddrNoPort, port := splitPort(ipaddr)
144 | // c := new(dns.Client)
145 | // laddr := net.UDPAddr{
146 | // IP: net.ParseIP(ipaddrNoPort),
147 | // Port: port,
148 | // Zone: "",
149 | // }
150 | // c.Dialer = &net.Dialer{
151 | // //Timeout: 200 * time.Millisecond,
152 | // LocalAddr: &laddr,
153 | // }
154 |
155 | c := dns.Client{}
156 | newM := dns.Msg{}
157 | newM.SetQuestion(q.Name, dns.TypeA)
158 | r, _, err := c.Exchange(&newM, burpIP+":"+os.Getenv("BURP_DNS_PORT"))
159 |
160 | if err != nil {
161 | Printy(err.Error(), 2)
162 | return
163 | }
164 | m.Answer = r.Answer
165 | // don't continue onto any other code paths if it's a collaborator message
166 | if os.Getenv("DEBUG") == "true" {
167 | Printy("Sent question "+q.Name+" to Collaborator: "+burpIP+":"+os.Getenv("BURP_DNS_PORT"), 3)
168 | }
169 | return
170 | }
171 |
172 | // for reverse proxy config
173 | if strings.HasSuffix(strings.ToLower(q.Name), strings.ToLower(os.Getenv("REVERSE_PROXY_DOMAIN"))+".") {
174 | // only proxy DNS if REVERSE_PROXY_DNS is configured
175 | if os.Getenv("REVERSE_PROXY_DNS") != "" {
176 | c := dns.Client{}
177 | newM := dns.Msg{}
178 | newM.SetQuestion(q.Name, dns.TypeA)
179 | r, _, err := c.Exchange(&newM, os.Getenv("REVERSE_PROXY_DNS"))
180 |
181 | if err != nil {
182 | Printy(err.Error(), 2)
183 | return
184 | }
185 | m.Answer = r.Answer
186 | if os.Getenv("DEBUG") == "true" {
187 | Printy("Proxied question "+q.Name+" to: "+os.Getenv("REVERSE_PROXY_DNS"), 3)
188 | }
189 | return
190 | } else {
191 | if os.Getenv("DEBUG") == "true" {
192 | Printy("REVERSE_PROXY_DNS not set, processing "+q.Name+" as normal knary request", 3)
193 | }
194 | // fall through to normal DNS processing
195 | }
196 | }
197 | }
198 |
199 | switch q.Qtype {
200 | case dns.TypeA:
201 | /*
202 | If we are an IPv6 host, to be a "compliant" nameserver (https://tools.ietf.org/html/rfc4074), we should:
203 | a) Return an empty response to A questions
204 | b) Return our SOA in the AUTHORITY section
205 | Let me know if you can do "b"
206 | */
207 | if IsIPv6(EXT_IP) {
208 | return
209 | }
210 |
211 | ipaddrNoPort, _ := splitPort(ipaddr)
212 | reverse, _ := dns.ReverseAddr(ipaddrNoPort)
213 | goSendMsg(ipaddr, reverse, q.Name, "A")
214 |
215 | if !foundInZone {
216 | rr, _ := dns.NewRR(fmt.Sprintf("%s IN 60 A %s", q.Name, EXT_IP))
217 | m.Answer = append(m.Answer, rr)
218 | }
219 |
220 | case dns.TypeAAAA:
221 | /*
222 | If we are an IPv4 host, to be a "compliant" nameserver (https://tools.ietf.org/html/rfc4074), we should:
223 | a) Return an empty response to AAAA questions
224 | b) Return our SOA in the AUTHORITY section
225 | Let me know if you can do "b"
226 | */
227 | if IsIPv4(EXT_IP) {
228 | return
229 | }
230 |
231 | ipaddrNoPort, _ := splitPort(ipaddr)
232 | reverse, _ := dns.ReverseAddr(ipaddrNoPort)
233 | goSendMsg(ipaddr, reverse, q.Name, "AAAA")
234 |
235 | if !foundInZone {
236 | rr, _ := dns.NewRR(fmt.Sprintf("%s IN 60 AAAA %s", q.Name, EXT_IP))
237 | m.Answer = append(m.Answer, rr)
238 | }
239 |
240 | case dns.TypeCNAME:
241 | if ok, _ := isRoot(q.Name); ok {
242 | // CNAME records cannot be returned for the root domain anyway.
243 | return
244 | }
245 |
246 | ipaddrNoPort, _ := splitPort(ipaddr)
247 | reverse, _ := dns.ReverseAddr(ipaddrNoPort)
248 | goSendMsg(ipaddr, reverse, q.Name, "CNAME")
249 |
250 | if !foundInZone {
251 | rr, _ := dns.NewRR(fmt.Sprintf("%s IN 60 CNAME %s", q.Name, q.Name))
252 | m.Answer = append(m.Answer, rr)
253 | }
254 |
255 | case dns.TypeTXT:
256 | ipaddrNoPort, _ := splitPort(ipaddr)
257 | reverse, _ := dns.ReverseAddr(ipaddrNoPort)
258 | goSendMsg(ipaddr, reverse, q.Name, "TXT")
259 |
260 | if !foundInZone {
261 | return
262 | }
263 |
264 | // for other nameserver functions
265 | case dns.TypeSOA:
266 | if os.Getenv("DEBUG") == "true" {
267 | Printy("Got SOA question for: "+q.Name, 3)
268 | }
269 |
270 | if !foundInZone {
271 | _, suffix := returnSuffix(q.Name)
272 | rr, _ := dns.NewRR(fmt.Sprintf("%s IN SOA %s %s (%s)", suffix, "ns."+suffix, "admin."+suffix, "2021041401 7200 3600 604800 300"))
273 | m.Answer = append(m.Answer, rr)
274 | }
275 |
276 | case dns.TypeNS:
277 | if os.Getenv("DEBUG") == "true" {
278 | Printy("Got NS question for: "+q.Name, 3)
279 | }
280 |
281 | if !foundInZone {
282 | _, suffix := returnSuffix(q.Name)
283 | rr, _ := dns.NewRR(fmt.Sprintf("%s IN NS %s", q.Name, "ns."+suffix))
284 | m.Answer = append(m.Answer, rr)
285 | }
286 | }
287 | }
288 | }
289 |
290 | func queryDNS(domain string, reqtype string, ns string) (string, error) {
291 | // Only supports A and NS records for now
292 | kMsg := new(dns.Msg)
293 |
294 | switch reqtype {
295 | case "A":
296 | kMsg.SetQuestion(dns.Fqdn(domain), dns.TypeA)
297 |
298 | case "NS":
299 | kMsg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
300 | }
301 |
302 | answ, _, err := new(dns.Client).Exchange(kMsg, ns+":53")
303 |
304 | if err != nil {
305 | return "", err
306 | }
307 |
308 | switch reqtype {
309 | case "A":
310 | if len(answ.Answer) == 0 {
311 | return "", errors.New("No response for A query: " + domain)
312 | }
313 |
314 | if t, ok := answ.Answer[0].(*dns.A); ok {
315 | if IsIP(t.A.String()) {
316 | return t.A.String(), nil
317 | } else {
318 | return "", errors.New("Malformed response from A question")
319 | }
320 | }
321 |
322 | case "NS":
323 | if len(answ.Ns) == 0 {
324 | return "", errors.New("No response for NS query: " + domain)
325 | }
326 |
327 | if t, ok := answ.Ns[0].(*dns.NS); ok {
328 | return t.Ns, nil
329 | }
330 | }
331 |
332 | return "", errors.New("Not an A or NS lookup")
333 | }
334 |
335 | func GuessIP(domain string) (string, error) {
336 | // query a root name server for the nameserver for our tld
337 | tldDNS, err := queryDNS(domain, "NS", "198.41.0.4")
338 |
339 | if err != nil {
340 | return "", err
341 | }
342 |
343 | // query the tld's nameserver for our knary domain and extract the glue record from additional information
344 | kMsg := new(dns.Msg)
345 | kMsg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
346 | answ, _, err := new(dns.Client).Exchange(kMsg, tldDNS+":53")
347 |
348 | if err != nil || answ == nil {
349 | return "", errors.New("DNS exchange failed for domain: " + domain + " with nameserver: " + tldDNS + ". Have you configured a glue record for your domain? Has it propagated? You can set EXT_IP to bypass this but... do you know what you're doing?")
350 | }
351 |
352 | if len(answ.Extra) == 0 {
353 | return "", errors.New("No 'Additional' section in NS lookup for: " + domain + " with nameserver: " + tldDNS + " Have you configured a glue record for your domain? Has it propagated? You can set EXT_IP to bypass this but... do you know what you're doing?")
354 | }
355 |
356 | if t, ok := answ.Extra[0].(*dns.A); ok {
357 | if IsIP(t.A.String()) {
358 | return t.A.String(), nil
359 | } else {
360 | return "", errors.New("Couldn't get glue record for " + domain + ". Have you configured a glue record for your domain? Has it propagated? You can set EXT_IP to bypass this but... do you know what you're doing?")
361 | }
362 | }
363 |
364 | return "", errors.New("Couldn't find glue record for " + domain + ". You can set EXT_IP to bypass this but... do you know what you're doing?")
365 | }
366 |
--------------------------------------------------------------------------------
/libknary/dns_test.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "net"
5 | "os"
6 | "testing"
7 |
8 | "github.com/miekg/dns"
9 | )
10 |
11 | func TestInfoLog(t *testing.T) {
12 | ipaddr := "127.0.0.1"
13 | reverse := "example.com"
14 | name := "example"
15 | infoLog(ipaddr, reverse, name)
16 |
17 | // Test passes if no panic occurs - this function only logs when DEBUG=true
18 | }
19 |
20 | func TestGuessIP_WithValidDomain(t *testing.T) {
21 | // Test the GuessIP function that we fixed the panic bug for
22 | // This tests our fix for issue #85
23 | domain := "nonexistent.tld"
24 |
25 | // This should return an error but NOT panic
26 | _, err := GuessIP(domain)
27 |
28 | if err == nil {
29 | t.Errorf("Expected error for non-existent domain, got nil")
30 | }
31 |
32 | // The test passes if it doesn't panic - our fix prevents the nil pointer dereference
33 | }
34 |
35 | func TestQueryDNS_ARecord(t *testing.T) {
36 | // Test A record query to a known DNS server
37 | result, err := queryDNS("google.com", "A", "8.8.8.8")
38 |
39 | if err != nil {
40 | t.Logf("DNS query failed (may be expected in test environment): %v", err)
41 | return // Don't fail test if DNS unavailable
42 | }
43 |
44 | if result == "" {
45 | t.Errorf("Expected non-empty result for A record query")
46 | }
47 |
48 | if !IsIP(result) {
49 | t.Errorf("Expected valid IP address, got: %s", result)
50 | }
51 | }
52 |
53 | func TestQueryDNS_NSRecord(t *testing.T) {
54 | // Test NS record query
55 | result, err := queryDNS("google.com", "NS", "8.8.8.8")
56 |
57 | if err != nil {
58 | t.Logf("DNS query failed (may be expected in test environment): %v", err)
59 | return
60 | }
61 |
62 | if result == "" {
63 | t.Errorf("Expected non-empty result for NS record query")
64 | }
65 | }
66 |
67 | func TestQueryDNS_InvalidType(t *testing.T) {
68 | // Test with invalid record type
69 | _, err := queryDNS("google.com", "INVALID", "8.8.8.8")
70 |
71 | if err == nil {
72 | t.Errorf("Expected error for invalid record type")
73 | }
74 | }
75 |
76 | func TestHandleDNS_ARecord(t *testing.T) {
77 | // Create test DNS message
78 | msg := new(dns.Msg)
79 | msg.SetQuestion("test.example.com.", dns.TypeA)
80 |
81 | // Create mock response writer
82 | mockAddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:12345")
83 | mockWriter := &mockResponseWriter{remoteAddr: mockAddr}
84 |
85 | // Test that HandleDNS doesn't panic
86 | HandleDNS(mockWriter, msg, "192.0.2.1")
87 |
88 | // Verify response was written
89 | if !mockWriter.written {
90 | t.Errorf("Expected response to be written")
91 | }
92 | }
93 |
94 | func TestGoSendMsg_WithDebug(t *testing.T) {
95 | // Test goSendMsg function with debug mode
96 | oldDebug := os.Getenv("DEBUG")
97 | os.Setenv("DEBUG", "true")
98 | defer os.Setenv("DEBUG", oldDebug)
99 |
100 | // This should not panic and should handle the filtering logic
101 | result := goSendMsg("127.0.0.1", "localhost", "test.example.com", "A")
102 |
103 | // The function returns false if not in allowlist or in denylist
104 | // Since we haven't configured lists, it should return false
105 | if result {
106 | t.Logf("goSendMsg returned true - message was sent")
107 | } else {
108 | t.Logf("goSendMsg returned false - message was filtered")
109 | }
110 | }
111 |
112 | func TestParseDNS_MultipleQuestions(t *testing.T) {
113 | // Create DNS message with multiple questions
114 | msg := new(dns.Msg)
115 | msg.SetQuestion("test1.example.com.", dns.TypeA)
116 | msg.Question = append(msg.Question, dns.Question{
117 | Name: "test2.example.com.",
118 | Qtype: dns.TypeAAAA,
119 | Qclass: dns.ClassINET,
120 | })
121 |
122 | // Test parsing doesn't panic with multiple questions
123 | parseDNS(msg, "127.0.0.1:12345", "192.0.2.1")
124 |
125 | // Test passes if no panic occurs
126 | }
127 |
128 | // Mock response writer for testing
129 | type mockResponseWriter struct {
130 | remoteAddr net.Addr
131 | written bool
132 | }
133 |
134 | func (m *mockResponseWriter) LocalAddr() net.Addr {
135 | return &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 53}
136 | }
137 |
138 | func (m *mockResponseWriter) RemoteAddr() net.Addr {
139 | return m.remoteAddr
140 | }
141 |
142 | func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error {
143 | m.written = true
144 | return nil
145 | }
146 |
147 | func (m *mockResponseWriter) Write([]byte) (int, error) {
148 | return 0, nil
149 | }
150 |
151 | func (m *mockResponseWriter) Close() error {
152 | return nil
153 | }
154 |
155 | func (m *mockResponseWriter) TsigStatus() error {
156 | return nil
157 | }
158 |
159 | func (m *mockResponseWriter) TsigTimersOnly(bool) {}
160 |
161 | func (m *mockResponseWriter) Hijack() {}
162 |
163 | func (m *mockResponseWriter) Network() string {
164 | return "udp"
165 | }
166 |
--------------------------------------------------------------------------------
/libknary/fsnotify.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | // Eventually this will support deny/allowlists too
4 |
5 | import (
6 | "log"
7 | "os"
8 | "time"
9 |
10 | "github.com/radovskyb/watcher"
11 | cmd "github.com/sudosammy/knary/v3/libknary/lego"
12 | )
13 |
14 | func TLSmonitor(restart chan bool) {
15 | w := watcher.New()
16 | // get filepath of certificate store
17 | certDir := cmd.GetCertPath()
18 | // Only notify write events
19 | w.FilterOps(watcher.Write)
20 |
21 | go func() {
22 | for {
23 | select {
24 | case event := <-w.Event:
25 | if event.Op == watcher.Write && event.IsDir() {
26 | continue // skip on folder changes
27 | }
28 | logger("INFO", "Server will reload on next HTTPS request to knary")
29 | if os.Getenv("DEBUG") == "true" {
30 | Printy("Server will reload on next HTTPS request to knary", 3)
31 | }
32 | restart <- true
33 | case err := <-w.Error:
34 | logger("ERROR", err.Error())
35 | GiveHead(2)
36 | log.Fatal(err)
37 | case <-w.Closed:
38 | return
39 | }
40 | }
41 | }()
42 |
43 | // watch the certificate directory for changes.
44 | if err := w.Add(certDir); err != nil {
45 | logger("ERROR", err.Error())
46 | GiveHead(2)
47 | log.Fatal(err)
48 | }
49 |
50 | // start the watching process - it'll check for changes every second.
51 | if err := w.Start(time.Second * 1); err != nil {
52 | log.Fatalln(err)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/libknary/http.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "log"
7 | "net"
8 | "net/http"
9 | "net/http/httputil"
10 | "os"
11 | "strconv"
12 | "strings"
13 | "sync"
14 | "time"
15 | )
16 |
17 | func Listen80() net.Listener {
18 | p80 := os.Getenv("BIND_ADDR") + ":80"
19 |
20 | if os.Getenv("BURP_HTTP_PORT") != "" || os.Getenv("REVERSE_PROXY_HTTP") != "" {
21 | p80 = "127.0.0.1:8880" // set local port that knary will listen on as the client of the reverse proxy
22 |
23 | // to support our container friends - let the player choose the IP Burp is bound to
24 | burpIP := ""
25 | if os.Getenv("BURP_INT_IP") != "" {
26 | burpIP = os.Getenv("BURP_INT_IP")
27 | } else {
28 | burpIP = "127.0.0.1"
29 | }
30 | // start custom handler to route requests appropriately
31 | go func() {
32 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 | // burp config
34 | if os.Getenv("BURP_DOMAIN") != "" && strings.HasSuffix(r.Host, os.Getenv("BURP_DOMAIN")) {
35 | proxy := &httputil.ReverseProxy{
36 | Director: func(req *http.Request) {
37 | req.URL.Scheme = "http"
38 | req.URL.Host = burpIP + ":" + os.Getenv("BURP_HTTP_PORT")
39 | },
40 | }
41 | proxy.ServeHTTP(w, r)
42 | return
43 | }
44 |
45 | // reverse proxy config
46 | if os.Getenv("REVERSE_PROXY_DOMAIN") != "" && strings.HasSuffix(r.Host, os.Getenv("REVERSE_PROXY_DOMAIN")) {
47 | proxy := &httputil.ReverseProxy{
48 | Director: func(req *http.Request) {
49 | req.URL.Scheme = "http"
50 | req.URL.Host = os.Getenv("REVERSE_PROXY_HTTP")
51 | },
52 | }
53 | proxy.ServeHTTP(w, r)
54 | return
55 | }
56 |
57 | // For knary canary requests, respond immediately without proxying
58 | r.Header.Set("X-Forwarded-For", r.RemoteAddr)
59 | })
60 |
61 | e := http.ListenAndServe(os.Getenv("BIND_ADDR")+":80", handler)
62 | if e != nil {
63 | Printy(e.Error(), 2)
64 | }
65 | }()
66 | }
67 |
68 | ln80, err := net.Listen("tcp", p80)
69 | if err != nil {
70 | logger("ERROR", err.Error())
71 | GiveHead(2)
72 | log.Fatal(err)
73 | }
74 |
75 | return ln80
76 | }
77 |
78 | func Accept80(ln net.Listener) {
79 | for {
80 | conn, err := ln.Accept() // accept connections forever
81 | if err != nil {
82 | Printy(err.Error(), 2)
83 | }
84 | go handleRequest(conn)
85 | }
86 | }
87 |
88 | func Listen443() net.Listener {
89 | p443 := os.Getenv("BIND_ADDR") + ":443"
90 |
91 | if os.Getenv("BURP_HTTPS_PORT") != "" || os.Getenv("REVERSE_PROXY_HTTPS") != "" {
92 | p443 = "127.0.0.1:8843" // set local port that knary will listen on as the client of the reverse proxy
93 |
94 | // to support our container friends - let the player choose the IP Burp is bound to
95 | burpIP := ""
96 | if os.Getenv("BURP_INT_IP") != "" {
97 | burpIP = os.Getenv("BURP_INT_IP")
98 | } else {
99 | burpIP = "127.0.0.1"
100 | }
101 | go func() {
102 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103 | // burp config
104 | if os.Getenv("BURP_DOMAIN") != "" && strings.HasSuffix(r.Host, os.Getenv("BURP_DOMAIN")) {
105 | proxy := &httputil.ReverseProxy{
106 | Transport: &http.Transport{
107 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //it's localhost, we don't need to verify
108 | },
109 | Director: func(req *http.Request) {
110 | req.URL.Scheme = "https"
111 | req.URL.Host = burpIP + ":" + os.Getenv("BURP_HTTPS_PORT")
112 | },
113 | }
114 | proxy.ServeHTTP(w, r)
115 | return
116 | }
117 |
118 | // reverse proxy config
119 | if os.Getenv("REVERSE_PROXY_DOMAIN") != "" && strings.HasSuffix(r.Host, os.Getenv("REVERSE_PROXY_DOMAIN")) {
120 | proxy := &httputil.ReverseProxy{
121 | Transport: &http.Transport{
122 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //it's localhost, we don't need to verify
123 | },
124 | Director: func(req *http.Request) {
125 | req.URL.Scheme = "https"
126 | req.URL.Host = os.Getenv("REVERSE_PROXY_HTTPS")
127 | },
128 | }
129 | proxy.ServeHTTP(w, r)
130 | return
131 | }
132 |
133 | // For knary canary requests, respond immediately without proxying
134 | r.Header.Set("X-Forwarded-For", r.RemoteAddr)
135 | })
136 |
137 | e := http.ListenAndServeTLS(os.Getenv("BIND_ADDR")+":443", os.Getenv("TLS_CRT"), os.Getenv("TLS_KEY"), handler)
138 | if e != nil {
139 | Printy(e.Error(), 2)
140 | }
141 | }()
142 | }
143 |
144 | cer, err := tls.LoadX509KeyPair(os.Getenv("TLS_CRT"), os.Getenv("TLS_KEY"))
145 | if err != nil {
146 | logger("ERROR", err.Error())
147 | GiveHead(2)
148 | log.Fatal(err)
149 | }
150 |
151 | config := &tls.Config{Certificates: []tls.Certificate{cer}}
152 | ln443, err := tls.Listen("tcp", p443, config)
153 | if err != nil {
154 | logger("ERROR", err.Error())
155 | GiveHead(2)
156 | log.Fatal(err)
157 | }
158 |
159 | return ln443
160 | }
161 |
162 | func Accept443(ln net.Listener, wg *sync.WaitGroup, restart <-chan bool) {
163 | for {
164 | select {
165 | case <-restart:
166 | ln.Close() // close listener so we can restart it
167 | ln443 := Listen443() // restart listener
168 | go Accept443(ln443, wg, restart)
169 | msg := "HTTPS / TLS server successfully reloaded."
170 | logger("INFO", msg)
171 | Printy(msg, 3)
172 | go sendMsg(":lock: " + msg)
173 | return // important
174 |
175 | default:
176 | conn, err := ln.Accept() // accept connections until channel says stop
177 | if err != nil {
178 | Printy(err.Error(), 2)
179 | }
180 | go handleRequest(conn)
181 | }
182 | }
183 | }
184 |
185 | func httpRespond(conn net.Conn) bool {
186 | conn.Write([]byte(" ")) // necessary as a 0 byte response triggers some clients to resend the request
187 | conn.Close() // v. important lol
188 | return true
189 | }
190 |
191 | func handleRequest(conn net.Conn) bool {
192 | // set timeout for reading responses
193 | _ = conn.SetDeadline(time.Now().Add(time.Second * time.Duration(2))) // 2 seconds
194 |
195 | // read & store <=4kb of request
196 | buf := make([]byte, 4096)
197 | recBytes, err := conn.Read(buf)
198 |
199 | if err != nil {
200 | Printy(err.Error(), 2)
201 | return false
202 | }
203 |
204 | response := string(buf[:recBytes])
205 | headers := strings.Split(response, "\n")
206 | lPort := conn.LocalAddr().(*net.TCPAddr).Port
207 |
208 | if os.Getenv("DEBUG") == "true" {
209 | Printy(conn.RemoteAddr().String(), 3)
210 | Printy(response, 3)
211 | }
212 |
213 | // search for our host header
214 | for _, header := range headers {
215 | if ok, _ := returnSuffix(header); ok {
216 | // a match made in heaven
217 | host := ""
218 | query := ""
219 | userAgent := ""
220 | cookie := ""
221 | fwd := ""
222 |
223 | for _, header := range headers {
224 | if stringContains(header, "Host") {
225 | host = strings.TrimRight(header, "\r\n") + ":"
226 | // using a reverse proxy, set ports back to the actual received ones
227 | if os.Getenv("BURP_HTTP_PORT") != "" || os.Getenv("BURP_HTTPS_PORT") != "" ||
228 | os.Getenv("REVERSE_PROXY_HTTP") != "" || os.Getenv("REVERSE_PROXY_HTTPS") != "" {
229 |
230 | if lPort == 8880 {
231 | host = host + "80"
232 | } else if lPort == 8843 {
233 | host = host + "443"
234 | }
235 | } else {
236 | host = host + strconv.Itoa(lPort)
237 | }
238 | }
239 | // https://github.com/sudosammy/knary/issues/17
240 | if stringContains(header, "OPTIONS ") ||
241 | stringContains(header, "GET ") ||
242 | stringContains(header, "HEAD ") ||
243 | stringContains(header, "POST ") ||
244 | stringContains(header, "PUT ") ||
245 | stringContains(header, "PATCH ") ||
246 | stringContains(header, "DELETE ") ||
247 | stringContains(header, "CONNECT ") {
248 | query = header
249 | }
250 | if stringContains(header, "User-Agent") {
251 | userAgent = header
252 | }
253 | if stringContains(header, "Cookie") {
254 | cookie = header
255 | }
256 | if stringContains(header, "X-Forwarded-For") {
257 | //this is pretty funny, and also very irritating.
258 | //Golang reverse proxy automagically adds the source IP address, but not the port.
259 | //We add the value we want in the prepareRequest function,
260 | //and strip off any values that don't have ports in this function.
261 | //It's then reconstructed and appended to the message
262 | val := strings.Split(header, ": ")[1]
263 | srcAndPort := []string{}
264 | mult := strings.Split(val, ",")
265 | if len(mult) > 1 {
266 | for _, srcaddr := range mult {
267 | if strings.Contains(srcaddr, ":") { // this probs breaks IPv6
268 | srcAndPort = append(srcAndPort, srcaddr)
269 | }
270 | }
271 | } else {
272 | srcAndPort = mult
273 | }
274 | fwd = strings.Join(srcAndPort, "")
275 | }
276 | }
277 |
278 | // take off the headers for the allow/denylist search
279 | searchUserAgent := strings.TrimPrefix(strings.ToLower(userAgent), "user-agent:")
280 | searchDomain := strings.TrimPrefix(strings.ToLower(host), "host:") // trim off the "Host:" section of header
281 |
282 | // these conditionals were bugged in <=3.4.6 whereby subdomains/ips in the allowlist weren't allowed unless the user-agent was ALSO in the allowlist
283 | // it should be easier to grok now
284 | if inBlacklist(searchUserAgent, searchDomain, conn.RemoteAddr().String(), fwd) { // inBlacklist returns false on empty/unused denylists
285 | return httpRespond(conn)
286 | }
287 |
288 | if !inAllowlist(searchUserAgent, searchDomain, conn.RemoteAddr().String(), fwd) { // inAllowlist returns true on empty/unused allowlists
289 | return httpRespond(conn)
290 | }
291 |
292 | var msg string
293 | var fromIP string
294 |
295 | if fwd != "" {
296 | fromIP = fwd // use this when burp collab mode is active
297 | } else {
298 | fromIP = conn.RemoteAddr().String()
299 | }
300 |
301 | if cookie != "" {
302 | if os.Getenv("FULL_HTTP_REQUEST") != "" {
303 | msg = fmt.Sprintf("%s\n```Query: %s\n%s\n%s\nFrom: %s\n\n---------- FULL REQUEST ----------\n%s\n----------------------------------", host, query, userAgent, cookie, fromIP, response)
304 | } else {
305 | msg = fmt.Sprintf("%s\n```Query: %s\n%s\n%s\nFrom: %s", host, query, userAgent, cookie, fromIP)
306 | }
307 | } else {
308 | if os.Getenv("FULL_HTTP_REQUEST") != "" {
309 | msg = fmt.Sprintf("%s\n```Query: %s\n%s\nFrom: %s\n\n---------- FULL REQUEST ----------\n%s\n----------------------------------", host, query, userAgent, fromIP, response)
310 | } else {
311 | msg = fmt.Sprintf("%s\n```Query: %s\n%s\nFrom: %s", host, query, userAgent, fromIP)
312 | }
313 | }
314 |
315 | go sendMsg(msg + "```")
316 | if os.Getenv("DEBUG") == "true" {
317 | logger("INFO", fromIP+" - "+host)
318 | }
319 | }
320 | }
321 |
322 | return httpRespond(conn)
323 | }
324 |
--------------------------------------------------------------------------------
/libknary/interface.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/fatih/color"
7 | )
8 |
9 | func GiveHead(colour int) {
10 | // make pretty [+] things
11 | green := color.New(color.FgGreen)
12 | red := color.New(color.FgRed)
13 | blue := color.New(color.FgBlue)
14 | white := color.New(color.FgWhite)
15 |
16 | switch colour {
17 | case 1: // success
18 | fmt.Printf("[")
19 | green.Printf("+")
20 | fmt.Printf("] ")
21 | case 2: // error
22 | fmt.Printf("[")
23 | red.Printf("+")
24 | fmt.Printf("] ")
25 | case 3: // debug
26 | fmt.Printf("[")
27 | blue.Printf("+")
28 | fmt.Printf("] ")
29 | default:
30 | fmt.Printf("[")
31 | white.Printf("+")
32 | fmt.Printf("] ")
33 | }
34 | }
35 |
36 | func Printy(msg string, col int) {
37 | GiveHead(col)
38 | fmt.Println(msg)
39 | }
40 |
--------------------------------------------------------------------------------
/libknary/lego/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015-2017 Sebastian Erhart
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/libknary/lego/account.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | // Many thanks to the original authors of this code
4 | // https://github.com/go-acme/lego/blob/83c626d9a1889fa499bc9c97bc2fdea965307002/cmd/account.go#L10
5 |
6 | import (
7 | "crypto"
8 |
9 | "github.com/go-acme/lego/v4/registration"
10 | )
11 |
12 | // Account represents a users local saved credentials.
13 | type Account struct {
14 | Email string `json:"email"`
15 | Registration *registration.Resource `json:"registration"`
16 | Key crypto.PrivateKey `json:"-"`
17 | }
18 |
19 | /** Implementation of the registration.User interface **/
20 |
21 | // GetEmail returns the email address for the account.
22 | func (a *Account) GetEmail() string {
23 | return a.Email
24 | }
25 |
26 | // GetPrivateKey returns the private RSA account key.
27 | func (a *Account) GetPrivateKey() crypto.PrivateKey {
28 | return a.Key
29 | }
30 |
31 | // GetRegistration returns the server registration.
32 | func (a *Account) GetRegistration() *registration.Resource {
33 | return a.Registration
34 | }
35 |
36 | /** End **/
37 |
--------------------------------------------------------------------------------
/libknary/lego/accounts_storage.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | // Many thanks to the original authors of this code
4 | // https://github.com/go-acme/lego/blob/83c626d9a1889fa499bc9c97bc2fdea965307002/cmd/accounts_storage.go
5 |
6 | import (
7 | "crypto"
8 | "crypto/x509"
9 | "encoding/json"
10 | "encoding/pem"
11 | "errors"
12 | "io/ioutil"
13 | "os"
14 | "path/filepath"
15 |
16 | "github.com/go-acme/lego/v4/certcrypto"
17 | "github.com/go-acme/lego/v4/lego"
18 | "github.com/go-acme/lego/v4/log"
19 | "github.com/go-acme/lego/v4/registration"
20 | )
21 |
22 | type AccountsStorage struct {
23 | userID string
24 | accountFilePath string
25 | keyFilePath string
26 | }
27 |
28 | // NewAccountsStorage Creates a new AccountsStorage.
29 | func NewAccountsStorage() *AccountsStorage {
30 | email := os.Getenv("LETS_ENCRYPT")
31 | return &AccountsStorage{
32 | userID: email,
33 | accountFilePath: filepath.Join(GetCertPath(), "account.json"),
34 | keyFilePath: filepath.Join(GetCertPath(), "knary.key"),
35 | }
36 | }
37 |
38 | func (s *AccountsStorage) ExistsAccountFilePath() bool {
39 | accountFile := s.accountFilePath
40 | if _, err := os.Stat(accountFile); os.IsNotExist(err) {
41 | return false
42 | } else if err != nil {
43 | log.Fatal(err)
44 | }
45 | return true
46 | }
47 |
48 | func (s *AccountsStorage) GetUserID() string {
49 | return s.userID
50 | }
51 |
52 | func (s *AccountsStorage) Save(account *Account) error {
53 | jsonBytes, err := json.MarshalIndent(account, "", "\t")
54 | if err != nil {
55 | return err
56 | }
57 |
58 | return ioutil.WriteFile(s.accountFilePath, jsonBytes, 0o600)
59 | }
60 |
61 | func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
62 | fileBytes, err := ioutil.ReadFile(s.accountFilePath)
63 | if err != nil {
64 | log.Fatalf("Could not load file for account %s: %v", s.userID, err)
65 | }
66 |
67 | var account Account
68 | err = json.Unmarshal(fileBytes, &account)
69 | if err != nil {
70 | log.Fatalf("Could not parse file for account %s: %v", s.userID, err)
71 | }
72 |
73 | account.Key = privateKey
74 |
75 | if account.Registration == nil || account.Registration.Body.Status == "" {
76 | reg, err := tryRecoverRegistration(privateKey)
77 | if err != nil {
78 | log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.userID, err)
79 | }
80 |
81 | account.Registration = reg
82 | err = s.Save(&account)
83 | if err != nil {
84 | log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.userID, err)
85 | }
86 | }
87 |
88 | return &account
89 | }
90 |
91 | func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.PrivateKey {
92 | accKeyPath := s.keyFilePath
93 |
94 | if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
95 | // log.Printf("No key found for account %s. Generating a %s key.", s.userID, keyType)
96 |
97 | privateKey, err := generatePrivateKey(accKeyPath, keyType)
98 | if err != nil {
99 | log.Fatalf("Could not generate RSA private account key for account %s: %v", s.userID, err)
100 | }
101 |
102 | // log.Printf("Saved key to %s", accKeyPath)
103 | return privateKey
104 | }
105 |
106 | privateKey, err := loadPrivateKey(accKeyPath)
107 | if err != nil {
108 | log.Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err)
109 | }
110 |
111 | return privateKey
112 | }
113 |
114 | func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.PrivateKey, error) {
115 | privateKey, err := certcrypto.GeneratePrivateKey(keyType)
116 | if err != nil {
117 | return nil, err
118 | }
119 |
120 | certOut, err := os.Create(file)
121 | if err != nil {
122 | return nil, err
123 | }
124 | defer certOut.Close()
125 |
126 | pemKey := certcrypto.PEMBlock(privateKey)
127 | err = pem.Encode(certOut, pemKey)
128 | if err != nil {
129 | return nil, err
130 | }
131 |
132 | return privateKey, nil
133 | }
134 |
135 | func loadPrivateKey(file string) (crypto.PrivateKey, error) {
136 | keyBytes, err := ioutil.ReadFile(file)
137 | if err != nil {
138 | return nil, err
139 | }
140 |
141 | keyBlock, _ := pem.Decode(keyBytes)
142 |
143 | switch keyBlock.Type {
144 | case "RSA PRIVATE KEY":
145 | return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
146 | case "EC PRIVATE KEY":
147 | return x509.ParseECPrivateKey(keyBlock.Bytes)
148 | }
149 |
150 | return nil, errors.New("unknown private key type")
151 | }
152 |
153 | func tryRecoverRegistration(privateKey crypto.PrivateKey) (*registration.Resource, error) {
154 | // couldn't load account but got a key. Try to look the account up.
155 | config := lego.NewConfig(&Account{Key: privateKey})
156 |
157 | if os.Getenv("LE_ENV") == "staging" {
158 | config.CADirURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
159 |
160 | } else if os.Getenv("LE_ENV") == "dev" {
161 | config.CADirURL = "http://127.0.0.1:4001/directory"
162 | }
163 |
164 | client, err := lego.NewClient(config)
165 | if err != nil {
166 | return nil, err
167 | }
168 |
169 | reg, err := client.Registration.ResolveAccountByKey()
170 | if err != nil {
171 | return nil, err
172 | }
173 | return reg, nil
174 | }
175 |
--------------------------------------------------------------------------------
/libknary/lego/cert_storage.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | // Many thanks to the original authors of this code
4 | // https://github.com/go-acme/lego/blob/83c626d9a1889fa499bc9c97bc2fdea965307002/cmd/certs_storage.go
5 |
6 | import (
7 | "bytes"
8 | "crypto/x509"
9 | "encoding/json"
10 | "io/ioutil"
11 | "os"
12 | "path/filepath"
13 | "strconv"
14 | "strings"
15 | "time"
16 |
17 | "github.com/go-acme/lego/v4/certcrypto"
18 | "github.com/go-acme/lego/v4/certificate"
19 | "github.com/go-acme/lego/v4/log"
20 | "golang.org/x/net/idna"
21 | )
22 |
23 | // GetCertPath():
24 | //
25 | // /knary/certs/
26 | // └── root certificates directory
27 | //
28 | // archive file path:
29 | //
30 | // /knary/certs/archives/
31 | // └── archived certificates directory
32 | func GetCertPath() string {
33 | var certFolderName string
34 | var certPath string
35 |
36 | if os.Getenv("TLS_CRT") == "" || os.Getenv("TLS_KEY") == "" {
37 | // this is the default LE config
38 | certPath = "./certs" // put LE certs in ./certs/* dir. if it doesn't exist, it'll be created by StartLetsEncrypt()
39 | } else {
40 | certPath = filepath.Dir(os.Getenv("TLS_CRT"))
41 | }
42 |
43 | if !filepath.IsAbs(certPath) {
44 | pwd, err := os.Getwd()
45 | if err != nil {
46 | log.Fatalf(err.Error())
47 | }
48 |
49 | path, err := filepath.Abs(filepath.Join(pwd, certPath))
50 | if err != nil {
51 | log.Fatalf(err.Error())
52 | }
53 |
54 | certFolderName = path
55 | } else {
56 | certFolderName = certPath
57 | }
58 |
59 | return certFolderName
60 | }
61 |
62 | type CertificatesStorage struct {
63 | rootPath string
64 | archivePath string
65 | pem bool
66 | }
67 |
68 | // NewCertificatesStorage create a new certificates storage.
69 | func NewCertificatesStorage() *CertificatesStorage {
70 | return &CertificatesStorage{
71 | rootPath: GetCertPath(),
72 | archivePath: filepath.Join(GetCertPath(), "archives"),
73 | pem: true,
74 | }
75 | }
76 |
77 | func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) {
78 | domain := certRes.Domain
79 |
80 | // We store the certificate, private key and metadata in different files
81 | // as web servers would not be able to work with a combined file.
82 | err := s.WriteFile(domain, ".crt", certRes.Certificate)
83 | if err != nil {
84 | log.Fatalf("Unable to save Certificate for domain %s\n\t%v", domain, err)
85 | }
86 |
87 | if certRes.IssuerCertificate != nil {
88 | err = s.WriteFile(domain, ".issuer.crt", certRes.IssuerCertificate)
89 | if err != nil {
90 | log.Fatalf("Unable to save IssuerCertificate for domain %s\n\t%v", domain, err)
91 | }
92 | }
93 |
94 | if certRes.PrivateKey != nil {
95 | // if we were given a CSR, we don't know the private key
96 | err = s.WriteFile(domain, ".key", certRes.PrivateKey)
97 | if err != nil {
98 | log.Fatalf("Unable to save PrivateKey for domain %s\n\t%v", domain, err)
99 | }
100 |
101 | if s.pem {
102 | err = s.WriteFile(domain, ".pem", bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil))
103 | if err != nil {
104 | log.Fatalf("Unable to save Certificate and PrivateKey in .pem for domain %s\n\t%v", domain, err)
105 | }
106 | }
107 | } else if s.pem {
108 | // we don't have the private key; can't write the .pem file
109 | log.Fatalf("Unable to save pem without private key for domain %s\n\t%v; are you using a CSR?", domain, err)
110 | }
111 |
112 | jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
113 | if err != nil {
114 | log.Fatalf("Unable to marshal CertResource for domain %s\n\t%v", domain, err)
115 | }
116 |
117 | err = s.WriteFile(domain, ".json", jsonBytes)
118 | if err != nil {
119 | log.Fatalf("Unable to save CertResource for domain %s\n\t%v", domain, err)
120 | }
121 | }
122 |
123 | func (s *CertificatesStorage) ReadResource(domain string) certificate.Resource {
124 | raw, err := s.ReadFile(domain, ".json")
125 | if err != nil {
126 | log.Fatalf("Error while loading the meta data for domain %s\n\t%v", domain, err)
127 | }
128 |
129 | var resource certificate.Resource
130 | if err = json.Unmarshal(raw, &resource); err != nil {
131 | log.Fatalf("Error while marshaling the meta data for domain %s\n\t%v", domain, err)
132 | }
133 |
134 | return resource
135 | }
136 |
137 | func (s *CertificatesStorage) ReadFile(domain, extension string) ([]byte, error) {
138 | return ioutil.ReadFile(s.GetFileName(domain, extension))
139 | }
140 |
141 | func (s *CertificatesStorage) GetFileName(domain, extension string) string {
142 | filename := SanitizedDomain(domain) + extension
143 | return filepath.Join(s.rootPath, filename)
144 | }
145 |
146 | func (s *CertificatesStorage) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) {
147 | content, err := s.ReadFile(domain, extension)
148 | if err != nil {
149 | return nil, err
150 | }
151 |
152 | // The input may be a bundle or a single certificate.
153 | return certcrypto.ParsePEMBundle(content)
154 | }
155 |
156 | func (s *CertificatesStorage) WriteFile(domain, extension string, data []byte) error {
157 | baseFileName := SanitizedDomain(domain)
158 | filePath := filepath.Join(s.rootPath, baseFileName+extension)
159 |
160 | return ioutil.WriteFile(filePath, data, 0400)
161 | }
162 |
163 | func (s *CertificatesStorage) MoveToArchive(domain string) error {
164 | matches, err := filepath.Glob(filepath.Join(s.rootPath, SanitizedDomain(domain)+".*"))
165 | if err != nil {
166 | return err
167 | }
168 |
169 | for _, oldFile := range matches {
170 | date := strconv.FormatInt(time.Now().Unix(), 10)
171 | filename := date + "." + filepath.Base(oldFile)
172 | newFile := filepath.Join(s.archivePath, filename)
173 |
174 | err = os.Rename(oldFile, newFile)
175 | if err != nil {
176 | return err
177 | }
178 | }
179 |
180 | return nil
181 | }
182 |
183 | // sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;)).
184 | func SanitizedDomain(domain string) string {
185 | safe, err := idna.ToASCII(strings.ReplaceAll(domain, "*", "_"))
186 | if err != nil {
187 | log.Fatal(err)
188 | }
189 | return safe
190 | }
191 |
--------------------------------------------------------------------------------
/libknary/lego/storage.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 | "os"
6 | "path/filepath"
7 | )
8 |
9 | func CreateFolderStructure() {
10 | folder := filepath.Join(GetCertPath(), "archives")
11 | err := os.MkdirAll(folder, os.ModePerm)
12 | if err != nil {
13 | log.Fatal(err)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/libknary/logging.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "os"
5 | "time"
6 | )
7 |
8 | func logger(status string, message string) {
9 | if os.Getenv("LOG_FILE") != "" {
10 | f, err := os.OpenFile(os.Getenv("LOG_FILE"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
11 |
12 | if err != nil {
13 | Printy(err.Error(), 2)
14 | }
15 |
16 | defer f.Close()
17 |
18 | // add newline if not present
19 | lastChar := message[len(message)-1:]
20 | var toLog string
21 |
22 | if lastChar != "\n" {
23 | toLog = message + "\n"
24 | } else {
25 | toLog = message
26 | }
27 |
28 | // log with timestamp
29 | if _, err = f.WriteString(time.Now().Format(time.RFC3339) + " - " + status + " - " + toLog); err != nil {
30 | Printy(err.Error(), 2)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/libknary/maintenance.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "os"
5 | "time"
6 | )
7 |
8 | func dailyTasks(version string, githubVersion string, githubURL string) bool {
9 | logger("INFO", "Daily maintenance tasks running")
10 | // check for updates
11 | CheckUpdate(version, githubVersion, githubURL)
12 |
13 | // flag any denied items that haven't had a hit in >14 days
14 | if os.Getenv("DENYLIST_ALERTING") != "false" {
15 | checkLastHit()
16 | }
17 |
18 | // if HTTPS knary is operating, check certificate expiry
19 | if os.Getenv("TLS_CRT") != "" && os.Getenv("TLS_KEY") != "" {
20 | _, _ = CheckTLSExpiry(30)
21 | }
22 |
23 | // log knary usage
24 | UsageStats(version)
25 |
26 | return true
27 | }
28 |
29 | func StartMaintenance(version string, githubVersion string, githubURL string) {
30 | // https://stackoverflow.com/questions/16466320/is-there-a-way-to-do-repetitive-tasks-at-intervals-in-golang
31 | dailyTicker := time.NewTicker(24 * time.Hour)
32 | hbTicker := time.NewTicker(24 * 7 * time.Hour) // once a week
33 | quit := make(chan struct{})
34 | go func() {
35 | for {
36 | select {
37 | case <-dailyTicker.C:
38 | dailyTasks(version, githubVersion, githubURL)
39 | case <-hbTicker.C:
40 | if os.Getenv("NO_HEARTBEAT_ALERT") == "true" {
41 | HeartBeat(version, false)
42 | }
43 | case <-quit:
44 | dailyTicker.Stop()
45 | hbTicker.Stop()
46 | return
47 | }
48 | }
49 | }()
50 | }
51 |
--------------------------------------------------------------------------------
/libknary/notificationctrl.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "bufio"
5 | "os"
6 | "strconv"
7 | "strings"
8 | "sync"
9 | "time"
10 | )
11 |
12 | // Functions that control whether a match will notify a webhook.
13 | // Currently the allow and denylists.
14 |
15 | // map for allowlist
16 | type allowlist struct {
17 | allow string
18 | }
19 |
20 | var allowed = map[int]allowlist{}
21 | var allowCount = 0
22 |
23 | // map for denylist
24 | type blacklist struct {
25 | mutex sync.Mutex
26 | deny map[string]time.Time
27 | }
28 |
29 | var denied = blacklist{deny: make(map[string]time.Time)}
30 | var denyCount = 0
31 |
32 | // add or update a denied domain/IP
33 | func (a *blacklist) updateD(term string) bool {
34 | if term == "" {
35 | return false // would happen if there's no X-Forwarded-For header
36 | }
37 | a.mutex.Lock()
38 | a.deny[term] = time.Now()
39 | a.mutex.Unlock()
40 | return true
41 | }
42 |
43 | // search for a denied domain/IP
44 | func (a *blacklist) searchD(term string) bool {
45 | a.mutex.Lock()
46 | defer a.mutex.Unlock()
47 |
48 | if _, ok := a.deny[term]; ok {
49 | return true // found!
50 | }
51 | return false
52 | }
53 |
54 | func standerdiseListItem(term string) string {
55 | d := strings.ToLower(term) // lowercase
56 | d = strings.TrimSpace(d) // remove any surrounding whitespaces
57 | var sTerm string
58 |
59 | if IsIP(d) {
60 | sTerm, _ = splitPort(d) // yeet port off IP
61 | } else {
62 | domain := strings.Split(d, ":") // split on port number (if exists)
63 | sTerm = strings.TrimSuffix(domain[0], ".") // remove trailing FQDN dot if present
64 | }
65 |
66 | return sTerm
67 | }
68 |
69 | func LoadAllowlist() (bool, error) {
70 | // load allowlist file into struct on startup
71 | if _, err := os.Stat(os.Getenv("ALLOWLIST_FILE")); os.IsNotExist(err) {
72 | return false, err
73 | }
74 |
75 | alwlist, err := os.Open(os.Getenv("ALLOWLIST_FILE"))
76 | if err != nil {
77 | Printy(err.Error()+" - ignoring", 3)
78 | return false, err
79 | }
80 | defer alwlist.Close()
81 |
82 | scanner := bufio.NewScanner(alwlist)
83 |
84 | for scanner.Scan() { // foreach allowed item
85 | if scanner.Text() != "" {
86 | allowed[allowCount] = allowlist{standerdiseListItem(scanner.Text())}
87 | allowCount++
88 | }
89 | }
90 |
91 | Printy("Monitoring "+strconv.Itoa(allowCount)+" items in allowlist", 1)
92 | logger("INFO", "Monitoring "+strconv.Itoa(allowCount)+" items in allowlist")
93 | return true, nil
94 | }
95 |
96 | func LoadBlacklist() (bool, error) {
97 | if os.Getenv("BLACKLIST_FILE") != "" {
98 | // deprecation warning
99 | Printy("The environment variable \"DENYLIST_FILE\" has superseded \"BLACKLIST_FILE\". Please update your configuration.", 2)
100 | }
101 | // load denylist file into struct on startup
102 | if _, err := os.Stat(os.Getenv("DENYLIST_FILE")); os.IsNotExist(err) {
103 | return false, err
104 | }
105 |
106 | blklist, err := os.Open(os.Getenv("DENYLIST_FILE"))
107 | if err != nil {
108 | Printy(err.Error()+" - ignoring", 3)
109 | return false, err
110 | }
111 | defer blklist.Close()
112 |
113 | scanner := bufio.NewScanner(blklist)
114 |
115 | for scanner.Scan() { // foreach denied item
116 | if scanner.Text() != "" {
117 | denied.updateD(standerdiseListItem(scanner.Text()))
118 | denyCount++
119 | }
120 | }
121 |
122 | Printy("Monitoring "+strconv.Itoa(denyCount)+" items in denylist", 1)
123 | logger("INFO", "Monitoring "+strconv.Itoa(denyCount)+" items in denylist")
124 | return true, nil
125 | }
126 |
127 | func inAllowlist(needles ...string) bool {
128 | if allowed[0].allow == "" {
129 | return true // if there is no allowlist set, we skip this check
130 | }
131 |
132 | for _, needle := range needles {
133 | needle := standerdiseListItem(needle)
134 | for i := range allowed { // foreach allowed item
135 | if os.Getenv("ALLOWLIST_STRICT") == "true" {
136 | // strict matching. don't match subdomains
137 | if needle == allowed[i].allow {
138 | if os.Getenv("DEBUG") == "true" {
139 | logger("INFO", "Found "+needle+" in allowlist (strict mode)")
140 | Printy(needle+" matches allowlist", 3)
141 | }
142 | return true
143 | }
144 | } else {
145 | // allow fuzzy matching
146 | if strings.HasSuffix(needle, allowed[i].allow) {
147 | if os.Getenv("DEBUG") == "true" {
148 | logger("INFO", "Found "+needle+" in allowlist")
149 | Printy(needle+" matches allowlist", 3)
150 | }
151 | return true
152 | }
153 | }
154 | }
155 | }
156 | return false
157 | }
158 |
159 | func inBlacklist(needles ...string) bool {
160 | for _, needle := range needles {
161 | needle := standerdiseListItem(needle)
162 | if denied.searchD(needle) {
163 | denied.updateD(needle) // found!
164 |
165 | if os.Getenv("DEBUG") == "true" {
166 | logger("INFO", "Found "+needle+" in denylist")
167 | Printy("Found "+needle+" in denylist", 3)
168 | }
169 | return true
170 | }
171 | }
172 | return false
173 | }
174 |
175 | func checkLastHit() bool { // this runs once a day
176 | for subdomain := range denied.deny {
177 | expiryDate := denied.deny[subdomain].AddDate(0, 0, 14)
178 |
179 | if time.Now().After(expiryDate) { // let 'em know it's old
180 | msg := "Denied item `" + subdomain + "` hasn't had a hit in >14 days. Consider removing it."
181 | go sendMsg(":wrench: " + msg + " Configure `DENYLIST_ALERTING` to suppress.")
182 | logger("INFO", msg)
183 | Printy(msg, 1)
184 | }
185 | }
186 |
187 | if os.Getenv("DEBUG") == "true" {
188 | logger("INFO", "Checked denylist...")
189 | Printy("Checked for old denylist items", 3)
190 | }
191 |
192 | return true
193 | }
194 |
--------------------------------------------------------------------------------
/libknary/util.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "bufio"
5 | "crypto/hmac"
6 | "crypto/sha256"
7 | "encoding/base64"
8 | "fmt"
9 | "net"
10 | "net/http"
11 | "os"
12 | "strconv"
13 | "strings"
14 | "time"
15 |
16 | "github.com/blang/semver/v4"
17 | )
18 |
19 | // domains to monitor
20 | var domains []string
21 |
22 | func LoadDomains(domainList string) error {
23 | prepareSplit := strings.ReplaceAll(domainList, " ", "")
24 | domains = strings.Split(prepareSplit, ",")
25 | return nil
26 | }
27 |
28 | func GetDomains() []string {
29 | return domains
30 | }
31 |
32 | func GetFirstDomain() string {
33 | return domains[0]
34 | }
35 |
36 | func returnSuffix(lookupVal string) (bool, string) {
37 | // we return bool for the http handleRequest()
38 | // we return string for the dns SOA and NS responses
39 | for _, suffix := range domains {
40 | if stringContains(lookupVal, suffix) || stringContains(lookupVal, suffix+".") {
41 | return true, suffix
42 | }
43 | }
44 | return false, ""
45 | }
46 |
47 | func isRoot(lookupVal string) (bool, error) {
48 | for _, prefix := range domains {
49 | if strings.HasPrefix(strings.ToLower(lookupVal), strings.ToLower(prefix+".")) {
50 | return true, nil
51 | }
52 | }
53 | return false, nil
54 | }
55 |
56 | // https://github.com/dsanader/govalidator/blob/master/validator.go
57 | func IsIP(str string) bool {
58 | return net.ParseIP(str) != nil
59 | }
60 | func IsIPv4(str string) bool {
61 | ip := net.ParseIP(str)
62 | return ip != nil && strings.Contains(str, ".")
63 | }
64 | func IsIPv6(str string) bool {
65 | ip := net.ParseIP(str)
66 | return ip != nil && strings.Contains(str, ":")
67 | }
68 |
69 | func stringContains(haystack string, needle string) bool {
70 | return strings.Contains(
71 | strings.ToLower(haystack),
72 | strings.ToLower(needle),
73 | )
74 | }
75 |
76 | // https://rosettacode.org/wiki/Parse_an_IP_Address#Go
77 | func splitPort(s string) (string, int) {
78 | ip := net.ParseIP(s)
79 | var port string
80 |
81 | if ip == nil {
82 | var host string
83 | var err error
84 | host, port, err = net.SplitHostPort(s)
85 | if err != nil {
86 | return "", 0
87 | }
88 |
89 | if port != "" {
90 | // This check only makes sense if service names are not allowed
91 | if _, err = strconv.ParseUint(port, 10, 16); err != nil {
92 | return "", 0
93 | }
94 | }
95 | ip = net.ParseIP(host)
96 | }
97 |
98 | if ip == nil {
99 | return "", 0
100 | } else {
101 | if ip4 := ip.To4(); ip4 != nil {
102 | ip = ip4
103 | }
104 | }
105 |
106 | stringIP := ip.String()
107 | intPort, _ := strconv.Atoi(port)
108 |
109 | if IsIP(stringIP) {
110 | return stringIP, intPort
111 | } else {
112 | return "", 0
113 | }
114 | }
115 |
116 | func CheckUpdate(version string, githubVersion string, githubURL string) (bool, error) { // this runs once a day
117 | running, err := semver.Make(version)
118 |
119 | if err != nil {
120 | updFail := "Could not check for updates: " + err.Error()
121 | Printy(updFail, 2)
122 | logger("WARNING", updFail)
123 |
124 | if os.Getenv("NO_UPDATES_ALERT") == "true" {
125 | go sendMsg(":warning: " + updFail)
126 | }
127 |
128 | return false, err
129 | }
130 |
131 | c := &http.Client{
132 | Timeout: 10 * time.Second,
133 | }
134 | response, err := c.Get(githubVersion)
135 |
136 | if err != nil {
137 | updFail := "Could not check for updates: " + err.Error()
138 | Printy(updFail, 2)
139 | logger("WARNING", updFail)
140 |
141 | if os.Getenv("NO_UPDATES_ALERT") == "true" {
142 | go sendMsg(":warning: " + updFail)
143 | }
144 |
145 | return false, err
146 | }
147 |
148 | defer response.Body.Close()
149 | scanner := bufio.NewScanner(response.Body) // refusing to import ioutil
150 |
151 | for scanner.Scan() { // foreach line
152 | current, err := semver.Make(scanner.Text())
153 |
154 | if err != nil {
155 | updFail := "Could not check for updates. GitHub response !semver format"
156 | Printy(updFail, 2)
157 | logger("WARNING", updFail)
158 | return false, err
159 | }
160 |
161 | if running.LT(current) {
162 | updMsg := "Your version of knary is *" + version + "* & the latest is *" + current.String() + "* - upgrade your binary here: " + githubURL
163 | Printy(updMsg, 2)
164 | logger("WARNING", updMsg)
165 |
166 | if os.Getenv("NO_UPDATES_ALERT") == "true" {
167 | go sendMsg(":warning: " + updMsg)
168 | }
169 |
170 | return true, nil
171 | }
172 | }
173 |
174 | if os.Getenv("DEBUG") == "true" {
175 | logger("INFO", "Checked for updates...")
176 | Printy("Checked for updates", 3)
177 | }
178 | return false, nil
179 | }
180 |
181 | func CheckTLSExpiry(days int) (bool, int) {
182 | if os.Getenv("TLS_CRT") != "" && os.Getenv("TLS_KEY") != "" {
183 | renew, expiry := needRenewal(days)
184 |
185 | if renew {
186 | Printy("TLS certificate expires in "+strconv.Itoa(expiry)+" days", 3)
187 | if os.Getenv("LETS_ENCRYPT") != "" {
188 | renewLetsEncrypt()
189 | }
190 | }
191 |
192 | if expiry <= 20 { // if cert expires in 20 days or less
193 | certMsg := "The TLS certificate for `" + os.Getenv("CANARY_DOMAIN") + "` expires in " + strconv.Itoa(expiry) + " days."
194 | Printy(certMsg, 2)
195 | logger("WARNING", certMsg)
196 |
197 | if os.Getenv("NO_CERT_EXPIRY_ALERT") == "true" {
198 | go sendMsg(":lock: " + certMsg)
199 | }
200 |
201 | return true, expiry
202 | }
203 |
204 | return false, expiry
205 | }
206 |
207 | if os.Getenv("DEBUG") == "true" {
208 | Printy("CheckTLSExpiry was called without any certificates being loaded...", 2)
209 | }
210 |
211 | return false, 0
212 | }
213 |
214 | func HeartBeat(version string, firstrun bool) (bool, error) {
215 | // runs weekly (and on launch) to let people know we're alive (and show them the denylist)
216 | beatMsg := "```"
217 | if firstrun {
218 | beatMsg += ` __
219 | | |--.-----.---.-.----.--.--.
220 | | <| | _ | _| | |
221 | |__|__|__|__|___._|__| |___ |` + "\n"
222 | beatMsg += ` @sudosammy v` + version + ` `
223 | beatMsg += `|_____|`
224 | beatMsg += "\n\n"
225 | } else {
226 | beatMsg += "❤️ Heartbeat (v" + version + ") ❤️\n"
227 | }
228 |
229 | // print TLS cert expiry
230 | if os.Getenv("TLS_CRT") != "" && os.Getenv("TLS_KEY") != "" {
231 | _, expiry := needRenewal(30)
232 | if expiry == 1 {
233 | beatMsg += "Certificate expiry in: " + strconv.Itoa(expiry) + " day\n"
234 | } else {
235 | beatMsg += "Certificate expiry in: " + strconv.Itoa(expiry) + " days\n"
236 | }
237 | }
238 |
239 | // print uptime
240 | if day == 1 {
241 | beatMsg += "Uptime: " + strconv.Itoa(day) + " day\n\n"
242 | } else {
243 | beatMsg += "Uptime: " + strconv.Itoa(day) + " days\n\n"
244 | }
245 |
246 | // print allowed items (if any)
247 | if allowCount > 0 {
248 | beatMsg += strconv.Itoa(allowCount) + " allowed subdomains, User-Agents, IPs: \n"
249 | if os.Getenv("ALLOWLIST_STRICT") == "true" {
250 | beatMsg += "(Operating in strict mode) \n"
251 | }
252 | beatMsg += "------------------------\n"
253 | for i := range allowed {
254 | beatMsg += allowed[i].allow + "\n"
255 | }
256 | beatMsg += "------------------------\n\n"
257 | }
258 |
259 | // print denied items (if any)
260 | if denyCount > 0 {
261 | beatMsg += strconv.Itoa(denyCount) + " denied subdomains, User-Agents, IPs: \n"
262 | beatMsg += "------------------------\n"
263 | for subdomain := range denied.deny {
264 | beatMsg += subdomain + "\n"
265 | }
266 | beatMsg += "------------------------\n\n"
267 | }
268 |
269 | // print usage domains
270 | if os.Getenv("HTTP") == "true" && (os.Getenv("TLS_CRT") == "" || os.Getenv("TLS_KEY") == "") {
271 | for _, cdomain := range GetDomains() {
272 | beatMsg += "Listening for http://*." + cdomain + " requests\n"
273 | }
274 | } else if os.Getenv("HTTP") == "true" && (os.Getenv("TLS_CRT") != "" && os.Getenv("TLS_KEY") != "") {
275 | for _, cdomain := range GetDomains() {
276 | beatMsg += "Listening for http(s)://*." + cdomain + " requests\n"
277 | }
278 | }
279 | if os.Getenv("DNS") == "true" {
280 | if os.Getenv("DNS_SUBDOMAIN") != "" {
281 | for _, cdomain := range GetDomains() {
282 | beatMsg += "Listening for *." + os.Getenv("DNS_SUBDOMAIN") + "." + cdomain + " DNS requests\n"
283 | }
284 | } else {
285 | for _, cdomain := range GetDomains() {
286 | beatMsg += "Listening for *." + cdomain + " DNS requests\n"
287 | }
288 | }
289 | }
290 | if os.Getenv("BURP_DOMAIN") != "" {
291 | beatMsg += "(Deprecated) Working in collaborator compatibility mode on subdomain *." + os.Getenv("BURP_DOMAIN") + "\n"
292 | }
293 | if os.Getenv("REVERSE_PROXY_DOMAIN") != "" {
294 | beatMsg += "Reverse proxy enabled on requests to: *." + os.Getenv("REVERSE_PROXY_DOMAIN") + "\n"
295 | }
296 | beatMsg += "```"
297 |
298 | go sendMsg(beatMsg)
299 |
300 | if os.Getenv("DEBUG") == "true" {
301 | logger("INFO", "Sent heartbeat...")
302 | Printy("Sent heartbeat message", 3)
303 | }
304 |
305 | return true, nil
306 | }
307 |
308 | // https://www.feishu.cn/hc/en-US/articles/360024984973-Bot-Use-bots-in-groups
309 | func SignLark(secret string, timestamp int64) (string, error) {
310 | //timestamp + key as sha256, then base64 encode
311 | stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret
312 |
313 | var data []byte
314 | h := hmac.New(sha256.New, []byte(stringToSign))
315 | _, err := h.Write(data)
316 | if err != nil {
317 | return "", err
318 | }
319 |
320 | signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
321 | return signature, nil
322 | }
323 |
324 | func fileExists(file string) bool {
325 | if _, err := os.Stat(file); os.IsNotExist(err) {
326 | return false
327 | } else if err != nil {
328 | logger("ERROR", err.Error())
329 | Printy(err.Error(), 2)
330 | return false
331 | }
332 | return true
333 | }
334 |
335 | func IsDeprecated(old string, new string, version string) {
336 | msg := "`" + old + "`" + " is deprecated. It will be removed in `" + version + "`. Change to: `" + new + "`"
337 | logger("WARNING", msg)
338 | Printy(msg, 3)
339 | go sendMsg(":warning: " + msg)
340 | }
341 |
--------------------------------------------------------------------------------
/libknary/util_test.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/rsa"
6 | "crypto/tls"
7 | "crypto/x509"
8 | "crypto/x509/pkix"
9 | "log"
10 | "math/big"
11 | "net"
12 | "net/http"
13 | "net/http/httptest"
14 | "os"
15 | "testing"
16 | "time"
17 | )
18 |
19 | const (
20 | VERSION = "3.3.0"
21 | GITHUB = "https://github.com/sudosammy/knary"
22 | GITHUBVERSION = "https://raw.githubusercontent.com/sudosammy/knary/master/VERSION"
23 | )
24 |
25 | func generateTLSConfig(eTime int) *tls.Config {
26 | //code taken and modified from here: https://golang.org/src/crypto/tls/generate_cert.go
27 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
28 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
29 | if err != nil {
30 | log.Fatalf("failed to generate serial number: %s", err)
31 | }
32 | xcrt := x509.Certificate{
33 | SerialNumber: serialNumber,
34 | Subject: pkix.Name{
35 | CommonName: "127.0.0.1",
36 | Organization: []string{"gokusec"},
37 | },
38 | IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
39 | DNSNames: []string{"127.0.0.1", "localhost"},
40 | NotBefore: time.Now(),
41 | NotAfter: time.Now().AddDate(0, 0, eTime),
42 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
43 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
44 | BasicConstraintsValid: true,
45 | }
46 | priv, err := rsa.GenerateKey(rand.Reader, 2048)
47 | if err != nil {
48 | panic(err)
49 | }
50 | crtBytes, e := x509.CreateCertificate(rand.Reader, &xcrt, &xcrt, priv.Public(), priv)
51 | if e != nil {
52 | panic(e)
53 | }
54 |
55 | crt := tls.Certificate{
56 | Certificate: [][]byte{crtBytes},
57 | PrivateKey: priv,
58 | }
59 | return &tls.Config{
60 | Certificates: []tls.Certificate{crt},
61 | }
62 | }
63 |
64 | func NewLocalHTTPSTestServer(handler http.Handler, eTime int) *httptest.Server {
65 | ts := httptest.NewUnstartedServer(handler)
66 | //get the tls config generated from the function
67 | config := generateTLSConfig(eTime)
68 | ts.TLS = config
69 | ts.StartTLS()
70 | return ts
71 | }
72 |
73 | func TestCheckUpdate(t *testing.T) {
74 | val, err := CheckUpdate(VERSION, GITHUBVERSION, GITHUB)
75 | if val == false && err != nil {
76 | t.Errorf("Cannot check for updates %s", err.Error())
77 | }
78 | }
79 |
80 | func TestLoadBlackList(t *testing.T) {
81 | createFile()
82 | f := openFile()
83 |
84 | //case 1 when env variable is not even set
85 | val, err := LoadBlacklist()
86 | if val == true && err == nil {
87 | t.Errorf("Expected to error out since DENYLIST_FILE env variable not set")
88 | }
89 |
90 | //second case env variable set but file not there
91 | os.Setenv("DENYLIST_FILE", "somerandomshit.txt")
92 | val, err = LoadBlacklist()
93 |
94 | if val == true && err == nil {
95 | t.Errorf("Expected a file error as filename is not present")
96 | }
97 |
98 | //third case, everything in place including env var and blacklist filename
99 | os.Setenv("DENYLIST_FILE", "blacklist_test.txt")
100 | val, err = LoadBlacklist()
101 |
102 | if val == false {
103 | t.Errorf("Expected file to load without any errors, BUT got: %s", err)
104 | }
105 |
106 | f.Close()
107 | deleteFile()
108 | }
109 |
110 | func TestStringContains(t *testing.T) {
111 | string1 := "gokuKaioKen"
112 | string2 := "goku"
113 |
114 | string3 := "Naruto"
115 | string4 := "zzz"
116 |
117 | if val := stringContains(string1, string2); val != true {
118 | //cant think of another meaningful error message, its just broken!
119 | t.Errorf("String contains is broken")
120 | }
121 |
122 | if val := stringContains(string3, string4); val == true {
123 | t.Errorf("String contains is broken")
124 | }
125 | }
126 |
127 | // simply clear the contents of a particular file
128 | // in this case blacklist_test.txt
129 | func clearFileContent(file string) {
130 | testFile, err := os.OpenFile(file, os.O_RDWR, 0666)
131 | if err != nil {
132 | panic(err)
133 | }
134 |
135 | defer testFile.Close()
136 | testFile.Truncate(0)
137 | testFile.Seek(0, 0)
138 | }
139 |
140 | // write some specific data to some specific file !
141 | func writeDataToFile(data string, f *os.File) {
142 | entry := []byte(data)
143 | _, err := f.Write(entry)
144 | if err != nil {
145 | panic(err)
146 | }
147 | }
148 |
149 | func createFile() {
150 | f, err := os.Create("blacklist_test.txt")
151 | if err != nil {
152 | panic(err)
153 | }
154 | f.Close()
155 | }
156 |
157 | func openFile() *os.File {
158 | f, err := os.OpenFile("blacklist_test.txt", os.O_WRONLY, 0666)
159 | if err != nil {
160 | panic(err)
161 | }
162 | return f
163 | }
164 |
165 | func deleteFile() {
166 | err := os.Remove("blacklist_test.txt")
167 | if err != nil {
168 | panic(err)
169 | }
170 | }
171 |
172 | func TestInBlacklist(t *testing.T) {
173 | os.Setenv("DENYLIST_FILE", "blacklist_test.txt")
174 | createFile()
175 | f := openFile()
176 | LoadBlacklist()
177 | dom := "mycanary.com"
178 | //first test is for empty blacklist file
179 | val := inBlacklist()
180 |
181 | if val == true {
182 | t.Errorf("Expected false since file is empty, Got true(there is a match)")
183 | }
184 |
185 | //second test is for an actual entry
186 | writeDataToFile("mycanary.com", f)
187 | LoadBlacklist()
188 | val = inBlacklist(dom)
189 |
190 | if val == false {
191 | t.Errorf("Expected true since entry is in blacklist but got false")
192 | }
193 |
194 | //test case for no match
195 | dom = "google.com"
196 | clearFileContent("blacklist_test.txt")
197 | writeDataToFile("mycanary.com", f)
198 | LoadBlacklist()
199 | val = inBlacklist(dom)
200 |
201 | if val == true {
202 | t.Errorf("Expected false since there is no match but got true")
203 | }
204 |
205 | // last test case to check if it matches x.mycanary.com when blacklist only says mycanary.com
206 |
207 | dom = "dns.mycanary.com"
208 | val = inBlacklist(dom)
209 |
210 | if val == true {
211 | t.Errorf("Expected false since it shouldnt match dns.mycanary.com when blacklist says mycanary.com")
212 | }
213 |
214 | f.Close()
215 | deleteFile()
216 | }
217 |
218 | func TestLoadDomains(t *testing.T) {
219 | // Test LoadDomains function
220 | oldDomains := os.Getenv("CANARY_DOMAIN")
221 | defer os.Setenv("CANARY_DOMAIN", oldDomains)
222 |
223 | // Test with single domain
224 | err := LoadDomains("example.com")
225 | if err != nil {
226 | t.Errorf("Expected no error, got %v", err)
227 | }
228 |
229 | domains := GetDomains()
230 | if len(domains) != 1 || domains[0] != "example.com" {
231 | t.Errorf("Expected [example.com], got %v", domains)
232 | }
233 |
234 | // Test with multiple domains
235 | err = LoadDomains("example.com,test.com,demo.org")
236 | if err != nil {
237 | t.Errorf("Expected no error, got %v", err)
238 | }
239 |
240 | domains = GetDomains()
241 | expected := []string{"example.com", "test.com", "demo.org"}
242 | if len(domains) != 3 {
243 | t.Errorf("Expected 3 domains, got %d", len(domains))
244 | }
245 |
246 | for i, expected := range expected {
247 | if domains[i] != expected {
248 | t.Errorf("Expected %s at index %d, got %s", expected, i, domains[i])
249 | }
250 | }
251 | }
252 |
253 | func TestGetFirstDomain(t *testing.T) {
254 | err := LoadDomains("first.com,second.com,third.com")
255 | if err != nil {
256 | t.Errorf("Expected no error, got %v", err)
257 | }
258 |
259 | first := GetFirstDomain()
260 | if first != "first.com" {
261 | t.Errorf("Expected first.com, got %s", first)
262 | }
263 | }
264 |
265 | func TestReturnSuffix(t *testing.T) {
266 | err := LoadDomains("example.com,test.org")
267 | if err != nil {
268 | t.Errorf("Expected no error, got %v", err)
269 | }
270 |
271 | // Test matching domain
272 | match, suffix := returnSuffix("Host: subdomain.example.com")
273 | if !match {
274 | t.Errorf("Expected match for subdomain.example.com")
275 | }
276 | if suffix != "example.com" {
277 | t.Errorf("Expected suffix example.com, got %s", suffix)
278 | }
279 |
280 | // Test non-matching domain
281 | match, suffix = returnSuffix("Host: other.domain.net")
282 | if match {
283 | t.Errorf("Expected no match for other.domain.net")
284 | }
285 |
286 | // Test exact match
287 | match, suffix = returnSuffix("Host: test.org")
288 | if !match {
289 | t.Errorf("Expected match for exact domain test.org")
290 | }
291 | if suffix != "test.org" {
292 | t.Errorf("Expected suffix test.org, got %s", suffix)
293 | }
294 | }
295 |
296 | func TestIsRoot(t *testing.T) {
297 | err := LoadDomains("example.com")
298 | if err != nil {
299 | t.Errorf("Expected no error, got %v", err)
300 | }
301 |
302 | // Test root domain
303 | isRootResult, err := isRoot("example.com.")
304 | if err != nil {
305 | t.Errorf("Expected no error, got %v", err)
306 | }
307 | if !isRootResult {
308 | t.Errorf("Expected true for root domain example.com.")
309 | }
310 |
311 | // Test subdomain
312 | isRootResult, err = isRoot("sub.example.com.")
313 | if err != nil {
314 | t.Errorf("Expected no error, got %v", err)
315 | }
316 | if isRootResult {
317 | t.Errorf("Expected false for subdomain sub.example.com.")
318 | }
319 |
320 | // Test non-matching domain
321 | isRootResult, err = isRoot("other.com.")
322 | if err != nil {
323 | t.Errorf("Expected no error, got %v", err)
324 | }
325 | if isRootResult {
326 | t.Errorf("Expected false for non-matching domain other.com.")
327 | }
328 | }
329 |
330 | func TestIsIPv4(t *testing.T) {
331 | // Test valid IPv4 addresses
332 | validIPv4 := []string{
333 | "192.168.1.1",
334 | "10.0.0.1",
335 | "172.16.0.1",
336 | "8.8.8.8",
337 | "127.0.0.1",
338 | }
339 |
340 | for _, ip := range validIPv4 {
341 | if !IsIPv4(ip) {
342 | t.Errorf("Expected %s to be valid IPv4", ip)
343 | }
344 | }
345 |
346 | // Test invalid IPv4 addresses
347 | invalidIPv4 := []string{
348 | "2001:db8::1",
349 | "not.an.ip",
350 | "256.256.256.256",
351 | "192.168.1",
352 | "",
353 | }
354 |
355 | for _, ip := range invalidIPv4 {
356 | if IsIPv4(ip) {
357 | t.Errorf("Expected %s to be invalid IPv4", ip)
358 | }
359 | }
360 | }
361 |
362 | func TestIsIPv6(t *testing.T) {
363 | // Test valid IPv6 addresses
364 | validIPv6 := []string{
365 | "2001:db8::1",
366 | "::1",
367 | "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
368 | "2001:db8:85a3::8a2e:370:7334",
369 | "::ffff:192.0.2.1",
370 | }
371 |
372 | for _, ip := range validIPv6 {
373 | if !IsIPv6(ip) {
374 | t.Errorf("Expected %s to be valid IPv6", ip)
375 | }
376 | }
377 |
378 | // Test invalid IPv6 addresses
379 | invalidIPv6 := []string{
380 | "192.168.1.1",
381 | "not.an.ip",
382 | "gggg::1",
383 | "2001:db8::1::2",
384 | "",
385 | }
386 |
387 | for _, ip := range invalidIPv6 {
388 | if IsIPv6(ip) {
389 | t.Errorf("Expected %s to be invalid IPv6", ip)
390 | }
391 | }
392 | }
393 |
394 | func TestSplitPort(t *testing.T) {
395 | // Test IPv4 with port
396 | host, port := splitPort("192.168.1.1:8080")
397 | if host != "192.168.1.1" {
398 | t.Errorf("Expected host 192.168.1.1, got %s", host)
399 | }
400 | if port != 8080 {
401 | t.Errorf("Expected port 8080, got %d", port)
402 | }
403 |
404 | // Test IPv4 without port (returns port 0)
405 | host, port = splitPort("192.168.1.1")
406 | if host != "192.168.1.1" {
407 | t.Errorf("Expected host 192.168.1.1, got %s", host)
408 | }
409 | if port != 0 {
410 | t.Errorf("Expected port 0 for IP without port, got %d", port)
411 | }
412 |
413 | // Test IPv6 with port
414 | host, port = splitPort("[2001:db8::1]:8080")
415 | if host != "2001:db8::1" {
416 | t.Errorf("Expected host 2001:db8::1, got %s", host)
417 | }
418 | if port != 8080 {
419 | t.Errorf("Expected port 8080, got %d", port)
420 | }
421 |
422 | // Test IPv6 without port (returns port 0)
423 | host, port = splitPort("2001:db8::1")
424 | if host != "2001:db8::1" {
425 | t.Errorf("Expected host 2001:db8::1, got %s", host)
426 | }
427 | if port != 0 {
428 | t.Errorf("Expected port 0 for IPv6 without port, got %d", port)
429 | }
430 |
431 | // Test hostname with port (splitPort only works with IP addresses, not hostnames)
432 | host, port = splitPort("example.com:9000")
433 | if host != "" {
434 | t.Errorf("Expected empty host for hostname (not IP), got %s", host)
435 | }
436 | if port != 0 {
437 | t.Errorf("Expected port 0 for hostname (not IP), got %d", port)
438 | }
439 |
440 | // Test invalid input
441 | host, port = splitPort("invalid")
442 | if host != "" {
443 | t.Errorf("Expected empty host for invalid input, got %s", host)
444 | }
445 | if port != 0 {
446 | t.Errorf("Expected port 0 for invalid input, got %d", port)
447 | }
448 | }
449 |
450 | func TestFileExists(t *testing.T) {
451 | // Create a temporary file
452 | createFile()
453 | defer deleteFile()
454 |
455 | // Test existing file
456 | if !fileExists("blacklist_test.txt") {
457 | t.Errorf("Expected blacklist_test.txt to exist")
458 | }
459 |
460 | // Test non-existing file
461 | if fileExists("nonexistent_file.txt") {
462 | t.Errorf("Expected nonexistent_file.txt to not exist")
463 | }
464 | }
465 |
466 | func TestLoadAllowlist(t *testing.T) {
467 | // Test when ALLOWLIST_FILE is not set (function expects error)
468 | oldAllowlist := os.Getenv("ALLOWLIST_FILE")
469 | defer os.Setenv("ALLOWLIST_FILE", oldAllowlist)
470 |
471 | os.Setenv("ALLOWLIST_FILE", "")
472 | result, err := LoadAllowlist()
473 |
474 | if result {
475 | t.Errorf("Expected false when ALLOWLIST_FILE not set, got %v", result)
476 | }
477 | if err == nil {
478 | t.Errorf("Expected error when ALLOWLIST_FILE not set, got nil")
479 | }
480 |
481 | // Test with non-existent file
482 | os.Setenv("ALLOWLIST_FILE", "nonexistent_allowlist.txt")
483 | result, err = LoadAllowlist()
484 |
485 | if result {
486 | t.Errorf("Expected false for non-existent file, got %v", result)
487 | }
488 | if err == nil {
489 | t.Errorf("Expected error for non-existent file, got nil")
490 | }
491 |
492 | // Test with valid allowlist file
493 | allowlistFile := "allowlist_test.txt"
494 | f, err := os.Create(allowlistFile)
495 | if err != nil {
496 | t.Fatal(err)
497 | }
498 | defer os.Remove(allowlistFile)
499 | defer f.Close()
500 |
501 | // Write test data
502 | f.WriteString("example.com\n192.168.1.1\ntest-user-agent\n")
503 | f.Close()
504 |
505 | os.Setenv("ALLOWLIST_FILE", allowlistFile)
506 | result, err = LoadAllowlist()
507 |
508 | if !result {
509 | t.Errorf("Expected true when loading valid allowlist file, got %v", result)
510 | }
511 | if err != nil {
512 | t.Errorf("Expected no error when loading valid allowlist file, got %v", err)
513 | }
514 | }
515 |
516 | func TestInAllowlist(t *testing.T) {
517 | // Test with empty allowlist (should return true - allow everything)
518 | oldAllowlist := os.Getenv("ALLOWLIST_FILE")
519 | defer os.Setenv("ALLOWLIST_FILE", oldAllowlist)
520 |
521 | os.Setenv("ALLOWLIST_FILE", "")
522 | LoadAllowlist()
523 |
524 | result := inAllowlist("user-agent", "example.com", "192.168.1.1", "")
525 | if !result {
526 | t.Errorf("Expected true for empty allowlist (allow all), got false")
527 | }
528 |
529 | // Test with populated allowlist
530 | allowlistFile := "allowlist_test.txt"
531 | f, err := os.Create(allowlistFile)
532 | if err != nil {
533 | t.Fatal(err)
534 | }
535 | defer os.Remove(allowlistFile)
536 |
537 | f.WriteString("example.com\n192.168.1.1\ntest-user-agent\n")
538 | f.Close()
539 |
540 | os.Setenv("ALLOWLIST_FILE", allowlistFile)
541 | LoadAllowlist()
542 |
543 | // Test domain match
544 | result = inAllowlist("", "example.com", "", "")
545 | if !result {
546 | t.Errorf("Expected true for domain in allowlist")
547 | }
548 |
549 | // Test IP match
550 | result = inAllowlist("", "", "192.168.1.1", "")
551 | if !result {
552 | t.Errorf("Expected true for IP in allowlist")
553 | }
554 |
555 | // Test user-agent match
556 | result = inAllowlist("test-user-agent", "", "", "")
557 | if !result {
558 | t.Errorf("Expected true for user-agent in allowlist")
559 | }
560 |
561 | // Test no match
562 | result = inAllowlist("other-agent", "other.com", "10.0.0.1", "")
563 | if result {
564 | t.Errorf("Expected false for items not in allowlist")
565 | }
566 | }
567 |
--------------------------------------------------------------------------------
/libknary/webhooks.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "net/http"
7 | "os"
8 | "regexp"
9 | "strings"
10 | "time"
11 | )
12 |
13 | func sendMsg(msg string) {
14 | // closes https://github.com/sudosammy/knary/issues/20
15 | re := regexp.MustCompile(`\r?\n`)
16 | msg = re.ReplaceAllString(msg, "\\n")
17 | msg = strings.ReplaceAll(msg, "\"", "\\\"")
18 |
19 | if os.Getenv("SLACK_WEBHOOK") != "" {
20 | jsonMsg := []byte(`{"username":"knary","icon_emoji":":bird:","text":"` + msg + `"}`)
21 | _, err := http.Post(os.Getenv("SLACK_WEBHOOK"), "application/json", bytes.NewBuffer(jsonMsg))
22 |
23 | if err != nil {
24 | Printy(err.Error(), 2)
25 | }
26 | }
27 |
28 | if os.Getenv("PUSHOVER_TOKEN") != "" && os.Getenv("PUSHOVER_USER") != "" {
29 | jsonMsg := []byte(`{"token":"` + os.Getenv("PUSHOVER_TOKEN") + `","user":"` + os.Getenv("PUSHOVER_USER") + `","message":"` + msg + `"}`)
30 | _, err := http.Post("https://api.pushover.net/1/messages.json/", "application/json", bytes.NewBuffer(jsonMsg))
31 |
32 | if err != nil {
33 | Printy(err.Error(), 2)
34 | }
35 | }
36 |
37 | if os.Getenv("TELEGRAM_CHATID") != "" && os.Getenv("TELEGRAM_BOT_TOKEN") != "" {
38 | msg = strings.ReplaceAll(msg, "```From:", "\nFrom:")
39 | re = regexp.MustCompile("```\\n?")
40 | msg = re.ReplaceAllString(msg, "")
41 |
42 | jsonMsg := []byte(`{"chat_id": "` + os.Getenv("TELEGRAM_CHATID") + `", "text": "` + msg + `"}`)
43 | _, err := http.Post("https://api.telegram.org/bot"+os.Getenv("TELEGRAM_BOT_TOKEN")+"/sendMessage", "application/json", bytes.NewBuffer(jsonMsg))
44 |
45 | if err != nil {
46 | Printy(err.Error(), 2)
47 | }
48 | }
49 |
50 | if os.Getenv("LARK_WEBHOOK") != "" {
51 | re = regexp.MustCompile("```\\n?")
52 | msg = re.ReplaceAllString(msg, "")
53 |
54 | jsonMsg := []byte("{\n")
55 |
56 | if larkSecret := os.Getenv("LARK_SECRET"); larkSecret != "" {
57 | // Generate signature
58 | timestamp := time.Now().Unix()
59 | sig, err := SignLark(os.Getenv("LARK_SECRET"), timestamp)
60 | if err != nil {
61 | Printy(err.Error(), 2)
62 | }
63 |
64 | // Add fields to payload
65 | sigFields := fmt.Sprintf(""+
66 | " \"timestamp\": \"%d\",\n"+
67 | " \"sign\": \"%s\",\n", timestamp, sig)
68 |
69 | jsonMsg = append(jsonMsg, sigFields...)
70 | }
71 |
72 | // Escape hell. Probably could have just backticked lol.
73 | postBody := fmt.Sprintf(""+
74 | " \"msg_type\": \"post\",\n"+
75 | " \"content\": {\n"+
76 | " \"post\": {\n"+
77 | " \"en_us\": {\n"+
78 | " \"title\": \"Knary Triggered 🐦\",\n"+
79 | " \"content\": [\n"+
80 | " [\n"+
81 | " {\n"+
82 | " \"tag\": \"text\",\n"+
83 | " \"text\": \"%s\"\n"+
84 | " }\n"+
85 | " ]\n"+
86 | " ]\n"+
87 | " }\n"+
88 | " }\n"+
89 | " }\n"+
90 | "}", msg)
91 |
92 | jsonMsg = append(jsonMsg, postBody...)
93 |
94 | _, err := http.Post(os.Getenv("LARK_WEBHOOK"), "application/json", bytes.NewBuffer(jsonMsg))
95 |
96 | if err != nil {
97 | Printy(err.Error(), 2)
98 | }
99 | }
100 |
101 | if os.Getenv("DISCORD_WEBHOOK") != "" {
102 | jsonMsg := []byte(`{"username":"knary","text":"` + msg + `"}`)
103 | _, err := http.Post(os.Getenv("DISCORD_WEBHOOK")+"/slack", "application/json", bytes.NewBuffer(jsonMsg))
104 |
105 | if err != nil {
106 | Printy(err.Error(), 2)
107 | }
108 | }
109 |
110 | if os.Getenv("TEAMS_WEBHOOK") != "" {
111 | // swap ``` with for MS teams :face-with-rolling-eyes:
112 | msg = strings.Replace(msg, "```", "
", 2)
113 | msg = strings.Replace(msg, "", "", 1)
114 |
115 | jsonMsg := []byte(`{"text":"` + msg + `"}`)
116 | _, err := http.Post(os.Getenv("TEAMS_WEBHOOK"), "application/json", bytes.NewBuffer(jsonMsg))
117 |
118 | if err != nil {
119 | Printy(err.Error(), 2)
120 | }
121 | }
122 |
123 | // should be simple enough to add support for other webhooks here
124 | }
125 |
--------------------------------------------------------------------------------
/libknary/webhooks_test.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "os"
7 | "testing"
8 | )
9 |
10 | func TestSendMsg(t *testing.T) {
11 | // Create a test server to capture HTTP requests
12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 | // Verify the request URL and method
14 | switch r.URL.String() {
15 | case "/":
16 | if r.Method != http.MethodPost {
17 | t.Errorf("Expected POST request for Slack webhook, got %s", r.Method)
18 | }
19 | default:
20 | t.Errorf("Unexpected request to URL: %s", r.URL.String())
21 | }
22 | }))
23 |
24 | defer server.Close()
25 |
26 | // Override the Slack webhook URL with the test server URL
27 | os.Setenv("SLACK_WEBHOOK", server.URL)
28 |
29 | // SLACK_WEBHOOK is set
30 | sendMsg("Test message for Slack")
31 | }
32 |
--------------------------------------------------------------------------------
/libknary/zones.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "os"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/miekg/dns"
11 | )
12 |
13 | /*
14 | LoadZone: Parse zone file and add to map
15 | inZone: Take a question name and type and return dns.RR response + bool if found
16 | */
17 | var zoneMap = map[string]map[int]dns.RR{}
18 | var fqdnCounter = map[string]int{}
19 | var zoneCounter = 0
20 |
21 | func LoadZone() (bool, error) {
22 | // Check if ZONE_FILE environment variable is set
23 | zoneFile := os.Getenv("ZONE_FILE")
24 | if zoneFile == "" {
25 | return true, nil
26 | }
27 |
28 | if _, err := os.Stat(zoneFile); os.IsNotExist(err) {
29 | return false, err
30 | }
31 |
32 | zlist, err := os.Open(zoneFile)
33 | if err != nil {
34 | Printy(err.Error()+" - ignoring", 3)
35 | return false, err
36 | }
37 | defer zlist.Close()
38 |
39 | // https://pkg.go.dev/github.com/miekg/dns#ZoneParser
40 | zp := dns.NewZoneParser(bufio.NewReader(zlist), "", "")
41 |
42 | for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
43 | if zoneMap[rr.Header().Name] == nil {
44 | zoneMap[rr.Header().Name] = map[int]dns.RR{}
45 | }
46 | zoneMap[rr.Header().Name][fqdnCounter[rr.Header().Name]] = rr
47 | fqdnCounter[rr.Header().Name]++
48 | zoneCounter++
49 | }
50 |
51 | if err := zp.Err(); err != nil {
52 | logger("ERROR", err.Error())
53 | return false, err
54 | }
55 |
56 | Printy("Monitoring "+strconv.Itoa(zoneCounter)+" items in zone", 1)
57 | logger("INFO", "Monitoring "+strconv.Itoa(zoneCounter)+" items in zone")
58 | return true, nil
59 | }
60 |
61 | func inZone(needle string, qType uint16) (map[int]dns.RR, bool) {
62 | // if last character of needle isn't a period, add it
63 | if needle[len(needle)-1] != '.' {
64 | needle += "."
65 | }
66 |
67 | if val, ok := zoneMap[strings.ToLower(needle)]; ok {
68 | // this (sub)domain is present in the zone file
69 | // confirm whether one or many match the qType
70 | var appendKey int
71 | returnMap := make(map[int]dns.RR)
72 | for k := range zoneMap[strings.ToLower(needle)] {
73 | if val[k].Header().Rrtype == qType {
74 | returnMap[appendKey] = val[k]
75 | appendKey++
76 | }
77 | }
78 | // catch if there were no matching qTypes
79 | if len(returnMap) == 0 {
80 | return nil, false
81 | }
82 |
83 | if os.Getenv("DEBUG") == "true" {
84 | Printy(needle+" found in zone file. Responding with "+strconv.Itoa(len(returnMap))+" response(s)", 3)
85 | }
86 | return returnMap, true
87 | }
88 | return nil, false
89 | }
90 |
91 | func addZone(fqdn string, ttl int, qType string, value string) error {
92 | rr, err := dns.NewRR(fmt.Sprintf("%s IN %d %s %s", strings.ToLower(fqdn), ttl, qType, value))
93 |
94 | if err != nil {
95 | Printy(err.Error(), 3)
96 | return err
97 | }
98 |
99 | nextVal := len(zoneMap[rr.Header().Name])
100 | if zoneMap[rr.Header().Name] == nil {
101 | zoneMap[rr.Header().Name] = map[int]dns.RR{}
102 | }
103 | zoneMap[rr.Header().Name][nextVal] = rr
104 |
105 | if os.Getenv("DEBUG") == "true" {
106 | Printy(fqdn+" "+qType+" added to zone with ID: "+strconv.Itoa(nextVal), 3)
107 | }
108 | return nil
109 | }
110 |
111 | func remZone(fqdn string) {
112 | // if last character of fqdn isn't a period, add it
113 | if fqdn[len(fqdn)-1] != '.' {
114 | fqdn += "."
115 | }
116 |
117 | // this is pretty dodgy.
118 | // we're hoping that the last zone added to the map is the one we want to delete
119 | lastVal := len(zoneMap[fqdn]) - 1
120 | _, ok := zoneMap[fqdn][lastVal]
121 | if ok {
122 | delete(zoneMap[fqdn], lastVal)
123 | if os.Getenv("DEBUG") == "true" {
124 | Printy("Deleted "+fqdn+" with ID: "+strconv.Itoa(lastVal)+" from zone", 3)
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/libknary/zones_test.go:
--------------------------------------------------------------------------------
1 | package libknary
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/miekg/dns"
8 | )
9 |
10 | func TestLoadZone_WhenZoneFileExists_ReturnsTrueAndNoError(t *testing.T) {
11 | os.Setenv("ZONE_FILE", ".zone_test.txt")
12 | if err := os.WriteFile(".zone_test.txt", []byte("example.com. IN A 192.0.2.1"), 0644); err != nil {
13 | t.Fatal(err)
14 | }
15 | defer os.Remove(".zone_test.txt")
16 |
17 | result, err := LoadZone()
18 |
19 | if err != nil {
20 | t.Errorf("Unexpected error: %v", err)
21 | }
22 | if result != true {
23 | t.Error("Expected result to be true")
24 | }
25 | }
26 |
27 | func TestLoadZone_WhenZoneFileInvalid_ReturnsError(t *testing.T) {
28 | os.Setenv("ZONE_FILE", ".zone_test.txt")
29 | // No trailing period
30 | if err := os.WriteFile(".zone_test.txt", []byte("example.com IN A 192.0.2.1"), 0644); err != nil {
31 | t.Fatal(err)
32 | }
33 | defer os.Remove(".zone_test.txt")
34 |
35 | result, err := LoadZone()
36 |
37 | if err == nil {
38 | t.Errorf("Expected an error")
39 | }
40 | if result == true {
41 | t.Error("Expected result to be false")
42 | }
43 | }
44 |
45 | func TestLoadZone_WhenZoneFileDoesNotExist_ReturnsFalseAndError(t *testing.T) {
46 | os.Setenv("ZONE_FILE", "nonexistent.txt")
47 |
48 | result, err := LoadZone()
49 |
50 | if err == nil {
51 | t.Error("Expected an error")
52 | }
53 | if result != false {
54 | t.Error("Expected result to be false")
55 | }
56 | }
57 |
58 | func TestLoadZone_WhenZoneFileNotSet_ReturnsTrueAndNoError(t *testing.T) {
59 | // Clear the ZONE_FILE environment variable
60 | os.Setenv("ZONE_FILE", "")
61 |
62 | result, err := LoadZone()
63 |
64 | if err != nil {
65 | t.Errorf("Unexpected error: %v", err)
66 | }
67 | if result != true {
68 | t.Error("Expected result to be true when ZONE_FILE is not set")
69 | }
70 | }
71 |
72 | func TestAddZone_WhenValidInput_ReturnsNoError(t *testing.T) {
73 | err := addZone("example.com", 3600, "A", "192.0.2.1")
74 |
75 | if err != nil {
76 | t.Errorf("Unexpected error: %v", err)
77 | }
78 | }
79 |
80 | func TestAddZone_WhenInvalidInput_ReturnsError(t *testing.T) {
81 | err := addZone("example.com", 3600, "InvalidType", "192.0.2.1")
82 |
83 | if err == nil {
84 | t.Error("Expected an error")
85 | }
86 | }
87 |
88 | func TestInZone_WhenZoneExists_ReturnsNoError(t *testing.T) {
89 | fqdn := "example.com"
90 | addZone(fqdn, 3600, "A", "192.0.2.1")
91 |
92 | rr, foundInZone := inZone(fqdn, dns.TypeA)
93 |
94 | if rr == nil {
95 | t.Error("Expected RR not found")
96 | }
97 |
98 | if foundInZone != true {
99 | t.Error("Expected zone not found")
100 | }
101 | }
102 |
103 | func TestInZone_WhenZoneDoesNotExist_ReturnsNoError(t *testing.T) {
104 | rr, foundInZone := inZone("not-exists.com", dns.TypeA)
105 |
106 | if rr != nil {
107 | t.Error("Unexpected RR found")
108 | }
109 |
110 | if foundInZone != false {
111 | t.Error("Unexpected zone found")
112 | }
113 | }
114 |
115 | func TestRemZone_WhenZoneExists_DeletesZoneAndReturnsNoError(t *testing.T) {
116 | fqdn := "another-example.com"
117 | err := addZone(fqdn, 3600, "A", "192.0.2.1")
118 |
119 | if err != nil {
120 | t.Errorf("Unexpected error: %v", err)
121 | }
122 |
123 | remZone(fqdn)
124 |
125 | // Check if zone is deleted
126 | _, foundInZone := inZone(fqdn, dns.TypeA)
127 | if foundInZone == true {
128 | t.Error("Expected zone not deleted")
129 | }
130 | }
131 |
132 | func TestRemZone_WhenZoneDoesNotExist_DoesNotDeleteZoneAndReturnsNoError(t *testing.T) {
133 | fqdn := "another-example.com"
134 | err := addZone(fqdn, 3600, "A", "192.0.2.2")
135 |
136 | if err != nil {
137 | t.Errorf("Unexpected error: %v", err)
138 | }
139 |
140 | remZone("not-exists.com")
141 |
142 | // Check if zone is not deleted
143 | _, foundInZone := inZone(fqdn, dns.TypeA)
144 | if foundInZone != true {
145 | t.Error("Unexpected zone deleted")
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/fatih/color"
5 | "github.com/joho/godotenv"
6 | "github.com/miekg/dns"
7 |
8 | "flag"
9 | "fmt"
10 | "log"
11 | "os"
12 | "sync"
13 |
14 | "github.com/sudosammy/knary/v3/libknary"
15 | )
16 |
17 | const (
18 | VERSION = "3.4.12"
19 | GITHUB = "https://github.com/sudosammy/knary"
20 | GITHUBVERSION = "https://raw.githubusercontent.com/sudosammy/knary/master/VERSION"
21 | )
22 |
23 | func main() {
24 | var helpS = flag.Bool("h", false, "Show help")
25 | var help = flag.Bool("help", false, "")
26 | var versionS = flag.Bool("v", false, "Show version")
27 | var version = flag.Bool("version", false, "")
28 | flag.Parse() // https://github.com/golang/go/issues/35761
29 | if *help || *helpS {
30 | libknary.Printy("Version: "+VERSION, 1)
31 | libknary.Printy("Find all configuration options and example .env files here: "+GITHUB+"/tree/master/examples", 3)
32 | os.Exit(0)
33 | }
34 | if *version || *versionS {
35 | libknary.Printy("Version: "+VERSION, 1)
36 | os.Exit(0)
37 | }
38 |
39 | // load enviro variables
40 | err := godotenv.Load()
41 | if os.Getenv("CANARY_DOMAIN") == "" {
42 | libknary.Printy("Required environment variables not found. Check location of .env file and/or running user's environment", 2)
43 | libknary.GiveHead(2)
44 | log.Fatal(err)
45 | }
46 |
47 | err = libknary.LoadDomains(os.Getenv("CANARY_DOMAIN"))
48 | if err != nil {
49 | libknary.GiveHead(2)
50 | log.Fatal(err)
51 | }
52 |
53 | // start maintenance timers
54 | libknary.StartMaintenance(VERSION, GITHUBVERSION, GITHUB)
55 |
56 | // get the glue record of knary to use in our responses
57 | var EXT_IP string
58 | if os.Getenv("EXT_IP") == "" {
59 | // try to guess the glue record
60 | res, err := libknary.GuessIP(libknary.GetFirstDomain())
61 |
62 | if err != nil {
63 | libknary.Printy("Are you sure your DNS is configured correctly?", 2)
64 | libknary.GiveHead(2)
65 | log.Fatal(err)
66 | }
67 |
68 | if !libknary.IsIP(res) {
69 | libknary.Printy("Couldn't parse response from glue record. You should set EXT_IP", 2)
70 | return
71 | }
72 |
73 | if os.Getenv("DEBUG") == "true" {
74 | libknary.Printy("Found glue record! We will answer DNS requests with: "+res, 3)
75 | }
76 |
77 | EXT_IP = res
78 | } else {
79 | // test that user inputted a valid IP addr.
80 | if !libknary.IsIP(os.Getenv("EXT_IP")) {
81 | libknary.Printy("Couldn't parse EXT_IP. Are you sure it's a valid IP address?", 2)
82 | return
83 | }
84 |
85 | EXT_IP = os.Getenv("EXT_IP")
86 | }
87 |
88 | // yo yo yo we doing a thing bb
89 | green := color.New(color.FgGreen)
90 | red := color.New(color.FgRed)
91 |
92 | red.Println(` __
93 | | |--.-----.---.-.----.--.--.
94 | | <| | _ | _| | |
95 | |__|__|__|__|___._|__| |___ |`)
96 | // Adjust spacing based on version number length
97 | digitCount := 0
98 | for _, char := range VERSION {
99 | if char >= '0' && char <= '9' {
100 | digitCount++
101 | }
102 | }
103 |
104 | var spacing string
105 | if digitCount >= 4 {
106 | spacing = " " // 4 spaces for versions with 4+ digits
107 | } else {
108 | spacing = " " // 5 spaces for versions with fewer than 4 digits
109 | }
110 |
111 | versionLine := fmt.Sprintf(` @sudosammy%sv%s `, spacing, VERSION)
112 | green.Printf("%s", versionLine)
113 | red.Println(`|_____|`)
114 | fmt.Println()
115 |
116 | // load lists, zone file & submit usage
117 | libknary.LoadAllowlist()
118 | libknary.LoadBlacklist()
119 |
120 | _, err = libknary.LoadZone()
121 | if err != nil {
122 | libknary.Printy("Error in zone file entries", 2)
123 | libknary.GiveHead(2)
124 | log.Fatal(err)
125 | }
126 |
127 | go libknary.UsageStats(VERSION)
128 |
129 | if os.Getenv("HTTP") == "true" && os.Getenv("LETS_ENCRYPT") == "" && (os.Getenv("TLS_CRT") == "" || os.Getenv("TLS_KEY") == "") {
130 | for _, cdomain := range libknary.GetDomains() {
131 | libknary.Printy("Listening for http://*."+cdomain+" requests", 1)
132 | }
133 | libknary.Printy("Without LETS_ENCRYPT or TLS_* environment variables set you will only be able to make HTTP (port 80) requests to knary", 2)
134 | } else if os.Getenv("HTTP") == "true" && (os.Getenv("LETS_ENCRYPT") != "" || os.Getenv("TLS_KEY") != "") {
135 | for _, cdomain := range libknary.GetDomains() {
136 | libknary.Printy("Listening for http(s)://*."+cdomain+" requests", 1)
137 | }
138 | }
139 | if os.Getenv("DNS") == "true" {
140 | if os.Getenv("DNS_SUBDOMAIN") != "" {
141 | for _, cdomain := range libknary.GetDomains() {
142 | libknary.Printy("Listening for *."+os.Getenv("DNS_SUBDOMAIN")+"."+cdomain+" DNS requests", 1)
143 | }
144 | } else {
145 | for _, cdomain := range libknary.GetDomains() {
146 | libknary.Printy("Listening for *."+cdomain+" DNS requests", 1)
147 | }
148 | }
149 | }
150 | if os.Getenv("BURP_DOMAIN") != "" {
151 | libknary.IsDeprecated("BURP_*", "REVERSE_PROXY_*", "3.5.0")
152 | libknary.Printy("(Deprecated) Working in collaborator compatibility mode on subdomain *."+os.Getenv("BURP_DOMAIN"), 1)
153 |
154 | if os.Getenv("BURP_DNS_PORT") == "" || os.Getenv("BURP_HTTP_PORT") == "" || os.Getenv("BURP_HTTPS_PORT") == "" {
155 | libknary.Printy("Not all Burp Collaborator settings are set. This might cause errors.", 2)
156 | }
157 | }
158 | if os.Getenv("REVERSE_PROXY_DOMAIN") != "" {
159 | libknary.Printy("Proxying enabled on requests to: *."+os.Getenv("REVERSE_PROXY_DOMAIN"), 1)
160 |
161 | if os.Getenv("REVERSE_PROXY_HTTP") == "" || os.Getenv("REVERSE_PROXY_HTTPS") == "" || os.Getenv("REVERSE_PROXY_DNS") == "" {
162 | libknary.Printy("Not all reverse proxy settings are set. This might cause errors.", 2)
163 | }
164 | }
165 | if os.Getenv("REVERSE_PROXY_DOMAIN") != "" && os.Getenv("BURP_DOMAIN") != "" {
166 | libknary.Printy("Configuring both BURP_* and REVERSE_PROXY_* is not supported and may break things!", 2)
167 | }
168 | if os.Getenv("SLACK_WEBHOOK") != "" {
169 | libknary.Printy("Posting to webhook: "+os.Getenv("SLACK_WEBHOOK"), 1)
170 | }
171 | if os.Getenv("DISCORD_WEBHOOK") != "" {
172 | libknary.Printy("Posting to webhook: "+os.Getenv("DISCORD_WEBHOOK"), 1)
173 | }
174 | if os.Getenv("PUSHOVER_USER") != "" {
175 | libknary.Printy("Posting to Pushover user: "+os.Getenv("PUSHOVER_USER"), 1)
176 | }
177 | if os.Getenv("TEAMS_WEBHOOK") != "" {
178 | libknary.Printy("Posting to webhook: "+os.Getenv("TEAMS_WEBHOOK"), 1)
179 | }
180 | if os.Getenv("LARK_WEBHOOK") != "" {
181 | libknary.Printy("Posting to webhook: "+os.Getenv("LARK_WEBHOOK"), 1)
182 | }
183 | if os.Getenv("TELEGRAM_CHATID") != "" {
184 | libknary.Printy("Posting to Telegram Chat ID: "+os.Getenv("TELEGRAM_CHATID"), 1)
185 | }
186 |
187 | // setup waitgroups for DNS/HTTP go routines
188 | var wg sync.WaitGroup // there isn't actually any clean exit option, so we can just wait forever
189 |
190 | if os.Getenv("DNS") == "true" {
191 | wg.Add(1)
192 | // https://bl.ocks.org/tianon/063c8083c215be29b83a
193 | // There must be a better way to pass "EXT_IP" along without an anonymous function AND copied variable
194 | for _, cdomain := range libknary.GetDomains() {
195 | dns.HandleFunc(cdomain+".", func(w dns.ResponseWriter, r *dns.Msg) { libknary.HandleDNS(w, r, EXT_IP) })
196 | }
197 | go libknary.AcceptDNS(&wg)
198 | }
199 |
200 | // generate a let's encrypt certificate
201 | if os.Getenv("LETS_ENCRYPT") != "" && os.Getenv("HTTP") == "true" && os.Getenv("DNS") == "true" && (os.Getenv("TLS_CRT") == "" || os.Getenv("TLS_KEY") == "") {
202 | libknary.StartLetsEncrypt()
203 | libknary.Printy("Let's Encrypt certificate is loaded", 1)
204 |
205 | } else if os.Getenv("LETS_ENCRYPT") != "" && (os.Getenv("HTTP") != "true" || os.Getenv("DNS") != "true") {
206 | libknary.Printy("HTTP and DNS environment variables must be set to \"true\" to use Let's Encrypt. We'll continue without Let's Encrypt", 2)
207 | os.Setenv("LETS_ENCRYPT", "") // clear variable to not confuse certificate renewal logic
208 |
209 | } else if os.Getenv("TLS_CRT") != "" && os.Getenv("LETS_ENCRYPT") != "" {
210 | libknary.Printy("TLS_* and LETS_ENCRYPT environment variables found. We'll use the TLS_* set certificates", 2)
211 | os.Setenv("LETS_ENCRYPT", "") // clear variable to not confuse certificate renewal logic
212 | }
213 |
214 | if os.Getenv("HTTP") == "true" {
215 | // HTTP
216 | ln80 := libknary.Listen80()
217 | wg.Add(1)
218 | go libknary.Accept80(ln80)
219 |
220 | if os.Getenv("TLS_CRT") != "" && os.Getenv("TLS_KEY") != "" {
221 | // HTTPS
222 | restart := make(chan bool)
223 | ln443 := libknary.Listen443()
224 | wg.Add(1)
225 | go libknary.Accept443(ln443, &wg, restart)
226 |
227 | _, _ = libknary.CheckTLSExpiry(30) // check TLS expiry on first launch of knary
228 | go libknary.TLSmonitor(restart) // monitor filesystem changes to the TLS cert to trigger a reboot
229 | }
230 | }
231 |
232 | // these go after all the screen printing for neatness
233 | libknary.CheckUpdate(VERSION, GITHUBVERSION, GITHUB)
234 | libknary.HeartBeat(VERSION, true)
235 |
236 | wg.Wait()
237 | }
238 |
--------------------------------------------------------------------------------
/screenshots/canary.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sudosammy/knary/d71563e4ffb74d6a13ed9423c814968436d0b34a/screenshots/canary.gif
--------------------------------------------------------------------------------
/screenshots/nameserver-ip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sudosammy/knary/d71563e4ffb74d6a13ed9423c814968436d0b34a/screenshots/nameserver-ip.png
--------------------------------------------------------------------------------
/screenshots/run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sudosammy/knary/d71563e4ffb74d6a13ed9423c814968436d0b34a/screenshots/run.png
--------------------------------------------------------------------------------
/test_knary.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # https://stackoverflow.com/questions/394230/how-to-detect-the-os-from-a-bash-script/18434831
4 | domain=$1
5 | if [[ -z "$domain" ]]; then
6 | echo "usage: $0 mycanary.com"
7 | exit 1
8 | fi
9 |
10 | echo "----------------------------------"
11 | echo "Tests running..."
12 | echo "----------------------------------"
13 |
14 | if [[ "$OSTYPE" == "linux-gnu" ]]; then
15 | curl "http://test.$domain"
16 | curl "https://test.$domain"
17 | dig "test.dns.$domain"
18 |
19 | elif [[ "$OSTYPE" == "darwin"* ]]; then
20 | curl "http://test.$domain"
21 | curl "https://test.$domain"
22 | dig "test.dns.$domain"
23 |
24 | elif [[ "$OSTYPE" == "cygwin" ]]; then
25 | curl "http://test.$domain"
26 | curl "https://test.$domain"
27 | nslookup "test.dns.$domain"
28 |
29 | elif [[ "$OSTYPE" == "msys" ]]; then
30 | curl "http://test.$domain"
31 | curl "https://test.$domain"
32 | nslookup "test.dns.$domain"
33 |
34 | elif [[ "$OSTYPE" == "win32" ]]; then
35 | # I'm not sure this can happen.
36 | nslookup "test.dns.$domain"
37 |
38 | elif [[ "$OSTYPE" == "freebsd"* ]]; then
39 | curl "http://test.$domain"
40 | curl "https://test.$domain"
41 | dig "test.dns.$domain"
42 | else
43 | echo "Unknown OS. Read script and run commands manually."
44 | fi
45 |
46 | echo "----------------------------------"
47 | echo "Check your webhook(s) for 3 hits!"
48 | echo "----------------------------------"
--------------------------------------------------------------------------------