chore: Restructure package directories
This commit is contained in:
parent
68fd19d487
commit
6b42a5ecf0
11 changed files with 4 additions and 4 deletions
77
cmd/api/api.go
Normal file
77
cmd/api/api.go
Normal 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
90
cmd/api/assets/main.js
Normal 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
201
cmd/api/assets/style.css
Normal 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
60
cmd/api/config.go
Normal 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
142
cmd/api/handlers.go
Normal 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
100
cmd/api/index.html
Normal 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>♥</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>
|
Loading…
Add table
Add a link
Reference in a new issue