chore: Restructure package directories

This commit is contained in:
Karan Sharma 2021-06-25 19:02:00 +05:30
parent 68fd19d487
commit 6b42a5ecf0
11 changed files with 4 additions and 4 deletions

77
cmd/api/api.go Normal file
View file

@ -0,0 +1,77 @@
package main
import (
"embed"
"io/fs"
"net/http"
"time"
"github.com/mr-karan/doggo/internal/app"
"github.com/mr-karan/doggo/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/knadh/koanf"
)
var (
logger = utils.InitLogger()
ko = koanf.New(".")
// Version and date of the build. This is injected at build-time.
buildVersion = "unknown"
buildDate = "unknown"
//go:embed assets/*
assetsDir embed.FS
//go:embed index.html
html []byte
)
func main() {
initConfig()
// Initialize app.
app := app.New(logger, buildVersion)
// Register router instance.
r := chi.NewRouter()
// Register middlewares
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Frontend Handlers.
assets, _ := fs.Sub(assetsDir, "assets")
r.Get("/assets/*", func(w http.ResponseWriter, r *http.Request) {
fs := http.StripPrefix("/assets/", http.FileServer(http.FS(assets)))
fs.ServeHTTP(w, r)
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/html")
w.Write(html)
})
// API Handlers.
r.Get("/api/", wrap(app, handleIndexAPI))
r.Get("/api/ping/", wrap(app, handleHealthCheck))
r.Post("/api/lookup/", wrap(app, handleLookup))
// HTTP Server.
srv := &http.Server{
Addr: ko.String("server.address"),
Handler: r,
ReadTimeout: ko.Duration("server.read_timeout") * time.Millisecond,
WriteTimeout: ko.Duration("server.write_timeout") * time.Millisecond,
IdleTimeout: ko.Duration("server.keepalive_timeout") * time.Millisecond,
}
logger.WithFields(logrus.Fields{
"address": srv.Addr,
}).Info("starting server")
if err := srv.ListenAndServe(); err != nil {
logger.Fatalf("couldn't start server: %v", err)
}
}

90
cmd/api/assets/main.js Normal file
View file

@ -0,0 +1,90 @@
const $ = document.querySelector.bind(document);
const $new = document.createElement.bind(document);
const $show = (el) => {
el.classList.remove('hidden');
};
const $hide = (el) => {
el.classList.add('hidden');
};
const apiURL = '/api/lookup/';
(function () {
const fields = ['name', 'address', 'type', 'ttl', 'rtt'];
// createRow creates a table row with the given cell values.
function createRow(item) {
const tr = $new('tr');
fields.forEach((f) => {
const td = $new('td');
td.innerText = item[f];
td.classList.add(f);
tr.appendChild(td);
});
return tr;
}
const handleSubmit = async () => {
const tbody = $('#table tbody'),
tbl = $('#table');
tbody.innerHTML = '';
$hide(tbl);
const q = $('input[name=q]').value.trim(),
typ = $('select[name=type]').value,
addr = $('input[name=address]').value.trim();
// Post to the API.
const req = await fetch(apiURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: [q,], type: [typ,], nameservers: [addr,] })
});
const res = await req.json();
if (res.status != 'success') {
const error = (res && res.message) || response.statusText;
throw(error);
return;
}
if (res.data[0].answers == null) {
throw('No records found.');
return;
}
res.data[0].answers.forEach((item) => {
tbody.appendChild(createRow(item));
});
$show(tbl);
};
// Capture the form submit.
$('#form').onsubmit = async (e) => {
e.preventDefault();
const msg = $('#message');
$hide(msg);
try {
await handleSubmit();
} catch(e) {
msg.innerText = e.toString();
$show(msg);
throw e;
}
};
// Change the address on ns change.
const ns = $("#ns"), addr = $("#address");
addr.value = ns.value;
ns.onchange = (e) => {
addr.value = e.target.value;
if(addr.value === "") {
addr.focus();
}
};
})();

201
cmd/api/assets/style.css Normal file
View file

@ -0,0 +1,201 @@
:root {
--primary: #4338ca;
--secondary: #333;
}
* {
box-sizing: border-box;
}
:focus {
outline: 0;
}
body {
font-family: "Segoe UI", "Helvetica Neue", Inter, sans-serif;
font-size: 16px;
line-height: 24px;
color: #111;
margin: 0 auto;
}
h1, h2, h3, h4 {
line-height: 1.3em;
}
a {
color: var(--primary);
}
a:hover {
color: #111;
text-decoration: none;
}
input, select, button {
border-radius: 5px;
border: 1px solid #ddd;
font-size: 1.3rem;
padding: 10px 15px;
width: 100%;
}
input:focus, select:focus {
border-color: var(--primary);
}
button {
border-color: var(--primary);
background: var(--primary);
color: #fff;
cursor: pointer;
width: auto;
padding: 10px 30px;
}
button:focus,
button:hover {
border-color: var(--secondary);
background: var(--secondary);
}
label {
display: block;
padding-bottom: 0.5rem;
}
.box {
box-shadow: 1px 1px 4px #eee;
border: 1px solid #eee;
padding: 30px;
border-radius: 3px;
}
.hidden {
display: none !important;
}
.main {
margin: 60px auto 30px auto;
max-width: 900px;
}
header {
text-align: center;
font-size: 1.5em;
margin-bottom: 60px;
}
.logo span {
color: var(--primary);
font-weight: 900;
}
form {
margin-bottom: 45px;
}
.row {
display: flex;
margin-bottom: 15px;
}
.row .field {
flex: 50%;
}
.row .field:last-child {
margin-left: 30px;
}
.submit {
text-align: right;
}
.help {
color: #666;
font-size: 0.875em;
}
#message {
color: #ff3300;
}
table.box {
width: 100%;
max-width: 100%;
padding: 0;
}
table th {
background: #f9fafb;
color: #666;
font-size: 0.875em;
border-bottom: 1px solid #ddd;
}
table th, tbody td {
padding: 10px 15px;
text-align: left;
}
td.name {
font-weight: bold;
}
th.type, td.type {
text-align: center;
}
td.type {
background: #d1fae5;
color: #065f46;
font-weight: bold;
}
footer {
margin: 60px 0 0 0;
text-align: center;
}
footer a {
text-decoration: none;
}
@media (max-width: 650px) {
.main {
margin: 60px 30px 30px 30px;
}
.box {
box-shadow: none;
border: 0;
padding: 0;
}
.row {
display: block;
}
.field {
margin: 0 0 20px 0;
}
.row .field:last-child {
margin: 0;
}
.submit button {
width: 100%;
}
table {
table-layout: fixed;
}
table th {
width: 100%;
}
table tr {
border-bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 30px;
}
table td {
border: 1px solid #eee;
margin: 0 -1px -1px 0;
position: relative;
width: 100%;
word-wrap:break-word;
}
table th.type, table td.type {
text-align: left;
}
table td span {
display: block;
}
}

60
cmd/api/config.go Normal file
View file

@ -0,0 +1,60 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/sirupsen/logrus"
flag "github.com/spf13/pflag"
)
// Config is the config given by the user
type Config struct {
HTTPAddr string `koanf:"listen_addr"`
}
func initConfig() {
f := flag.NewFlagSet("api", flag.ContinueOnError)
f.Usage = func() {
fmt.Println(f.FlagUsages())
os.Exit(0)
}
// Register --config flag.
f.StringSlice("config", []string{"config.toml"},
"Path to one or more TOML config files to load in order")
// Register --version flag.
f.Bool("version", false, "Show build version")
f.Parse(os.Args[1:])
// Display version.
if ok, _ := f.GetBool("version"); ok {
fmt.Println(buildVersion, buildDate)
os.Exit(0)
}
// Read the config files.
cFiles, _ := f.GetStringSlice("config")
for _, f := range cFiles {
logger.WithFields(logrus.Fields{
"file": f,
}).Info("reading config")
if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
logger.Fatalf("error reading config: %v", err)
}
}
// Load environment variables and merge into the loaded config.
if err := ko.Load(env.Provider("DOGGO_API_", ".", func(s string) string {
return strings.Replace(strings.ToLower(
strings.TrimPrefix(s, "DOGGO_API_")), "__", ".", -1)
}), nil); err != nil {
logger.Fatalf("error loading env config: %v", err)
}
ko.Load(posflag.Provider(f, ".", ko), nil)
}

142
cmd/api/handlers.go Normal file
View file

@ -0,0 +1,142 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/mr-karan/doggo/internal/app"
"github.com/mr-karan/doggo/pkg/models"
"github.com/mr-karan/doggo/pkg/resolvers"
)
type httpResp struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
func handleIndexAPI(w http.ResponseWriter, r *http.Request) {
var (
app = r.Context().Value("app").(app.App)
)
sendResponse(w, http.StatusOK, fmt.Sprintf("Welcome to Doggo API. Version: %s", app.Version))
return
}
func handleHealthCheck(w http.ResponseWriter, r *http.Request) {
sendResponse(w, http.StatusOK, "PONG")
return
}
func handleLookup(w http.ResponseWriter, r *http.Request) {
var (
app = r.Context().Value("app").(app.App)
)
// Read body.
b, err := ioutil.ReadAll(r.Body)
defer r.Body.Close()
if err != nil {
app.Logger.WithError(err).Error("error reading request body")
sendErrorResponse(w, fmt.Sprintf("Invalid JSON payload"), http.StatusBadRequest, nil)
return
}
// Prepare query flags.
var qFlags models.QueryFlags
if err := json.Unmarshal(b, &qFlags); err != nil {
app.Logger.WithError(err).Error("error unmarshalling payload")
sendErrorResponse(w, fmt.Sprintf("Invalid JSON payload"), http.StatusBadRequest, nil)
return
}
app.QueryFlags = qFlags
// Load fallbacks.
app.LoadFallbacks()
// Load Questions.
app.PrepareQuestions()
if len(app.Questions) == 0 {
sendErrorResponse(w, fmt.Sprintf("Missing field `query`."), http.StatusBadRequest, nil)
return
}
// Load Nameservers.
err = app.LoadNameservers()
if err != nil {
app.Logger.WithError(err).Error("error loading nameservers")
sendErrorResponse(w, fmt.Sprintf("Error looking up for records."), http.StatusInternalServerError, nil)
return
}
// Load Resolvers.
rslvrs, err := resolvers.LoadResolvers(resolvers.Options{
Nameservers: app.Nameservers,
UseIPv4: app.QueryFlags.UseIPv4,
UseIPv6: app.QueryFlags.UseIPv6,
SearchList: app.ResolverOpts.SearchList,
Ndots: app.ResolverOpts.Ndots,
Timeout: app.QueryFlags.Timeout * time.Second,
Logger: app.Logger,
})
if err != nil {
app.Logger.WithError(err).Error("error loading resolver")
sendErrorResponse(w, fmt.Sprintf("Error looking up for records."), http.StatusInternalServerError, nil)
return
}
app.Resolvers = rslvrs
var responses []resolvers.Response
for _, q := range app.Questions {
for _, rslv := range app.Resolvers {
resp, err := rslv.Lookup(q)
if err != nil {
app.Logger.WithError(err).Error("error looking up DNS records")
sendErrorResponse(w, fmt.Sprintf("Error looking up for records."), http.StatusInternalServerError, nil)
return
}
responses = append(responses, resp)
}
}
sendResponse(w, http.StatusOK, responses)
return
}
// wrap is a middleware that wraps HTTP handlers and injects the "app" context.
func wrap(app app.App, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "app", app)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sendResponse sends a JSON envelope to the HTTP response.
func sendResponse(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
out, err := json.Marshal(httpResp{Status: "success", Data: data})
if err != nil {
sendErrorResponse(w, "Internal Server Error", http.StatusInternalServerError, nil)
return
}
w.Write(out)
}
// sendErrorResponse sends a JSON error envelope to the HTTP response.
func sendErrorResponse(w http.ResponseWriter, message string, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
resp := httpResp{Status: "error",
Message: message,
Data: data}
out, _ := json.Marshal(resp)
w.Write(out)
}

100
cmd/api/index.html Normal file
View file

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<title>Doggo DNS</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<link href="assets/style.css" rel="stylesheet">
</head>
<body>
<div class="main">
<header>
<h1 class="logo"><span>Doggo</span> DNS</h1>
</header>
<form method="post" id="form" class="box">
<div class="row">
<div class="field">
<label for="domain">Domain name</label>
<input id="domain" name="q" placeholder="domain.tld" required autofocus />
</div>
<div class="field">
<label for="type">Query type</label>
<select id="type" name="type">
<option default>A</option>
<option>AAAA</option>
<option>CAA</option>
<option>CNAME</option>
<option>MX</option>
<option>NAPTR</option>
<option>NS</option>
<option>PTR</option>
<option>SOA</option>
<option>SRV</option>
<option>TXT</option>
</select>
</div>
</div>
<div class="row">
<div class="field">
<label for="ns">Nameserver</label>
<select id="ns" name="ns">
<option default value="tcp://1.1.1.1:53">Cloudflare</option>
<option value="https://cloudflare-dns.com/dns-query">Cloudflare (DOH)</option>
<option value="tcp://8.8.8.8:53">Google</option>
<option value="tcp://9.9.9.9:53">Quad9</option>
<option value="">Custom</option>
</select>
</div>
<div class="field">
<label for="address">Nameserver address</label>
<input id="address" name="address" type="text" placeholder="tcp://your-ip"
required pattern="(tcp|udp|tls|https|sdns):\/\/(.*)" />
<p class="help">
To use different protocols like DOH, DOT etc. refer to the instructions
<a href="https://github.com/mr-karan/doggo#transport-options">here</a>.
</p>
</div>
</div>
<div class="row">
<div class="field"><p id="message"></p></div>
<div class="field submit">
<button type="submit">Submit</button>
</div>
</div>
</form>
<!--Responses-->
<table class="box hidden" id="table">
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th class="type">Type</th>
<th>TTL</th>
<th>RTT</th>
</tr>
</thead>
<tbody></tbody>
</table>
<footer >
<div>
<p>Built with
<span>&#9829;</span> by
<a href="https://mrkaran.dev"><strong>mrkaran</strong></a>
</p>
<p><a href="https://github.com/mr-karan/doggo">Source Code</a></p>
</div>
</footer>
<script src="assets/main.js"> </script>
<noscript>
<div class="noscript">
<h2>This service requires Javascript</h2>
<p>Please enable JavaScript so that the form data can be sent to the API backend.</p>
</div>
</noscript>
</div>
</body>
</html>