├── .gitignore ├── INSTALL.md ├── LICENSE ├── README.md ├── admin.go ├── blacklist.go ├── config.yml ├── db.go ├── geofence.go ├── geofence_test.go ├── go.mod ├── go.sum ├── main.go ├── notfound.go ├── package.json ├── scss └── zerodrop.scss ├── shot.go ├── static ├── .gitignore └── root │ └── robots.txt ├── systemd └── zerodrop@.service ├── templates ├── entries.tmpl ├── footer.tmpl ├── header.tmpl ├── login.tmpl └── new.tmpl ├── ts ├── emoji-favicon-toolkit.ts └── zerodrop.ts ├── tsconfig.json ├── util.go ├── util_test.go ├── webpack.config.js ├── yarn.lock └── zerodrop.go /.gitignore: -------------------------------------------------------------------------------- 1 | zerodrop 2 | *.db 3 | *.mmdb 4 | *.sock 5 | 6 | # Uploads folder 7 | /uploads/ 8 | 9 | # Webpack 10 | /node_modules/ 11 | 12 | # IDEs 13 | /.vscode/ 14 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Zerodrop Installation 2 | 3 | Zerodrop runs as a single binary (like most Go programs) that references the 4 | `templates` directory in the current working directory. 5 | 6 | ## Simple 7 | 8 | Use the following commands for a quick and easy demo of Zerodrop. 9 | 10 | ```sh 11 | go get github.com/oftn-oswg/zerodrop 12 | cd $GOPATH/src/oftn-oswg/zerodrop 13 | 14 | yarn install # or npm install 15 | yarn run css # or npm run css 16 | yarn run dist # or npm run dist 17 | 18 | # EDIT config.yml to your liking. 19 | # REMEMBER to set up your secrets: 20 | # 1) authsecret: cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 21 | # 2) authdigest: echo -n YOUR_PASSWORD_HERE | sha256sum | cut -c -64 22 | 23 | $GOPATH/bin/zerodrop -config config.yml 24 | ``` 25 | 26 | ## Systemd 27 | 28 | Use the following commands for a more production-ready setup with **systemd** on 29 | Ubuntu. This assumes the following. 30 | - You have created a user called `zerodrop` with the home directory of `/home/zerodrop`. 31 | - You have the source downloaded into `$GOPATH/src/github.com/oftn-oswg/zerodrop/`. 32 | - You have installed the binary into `$GOPATH/bin/zerodrop`. 33 | 34 | ### As zerodrop user 35 | 36 | ```sh 37 | # Navigate to /home/zerodrop 38 | cd 39 | 40 | ZERODROP_NAME=drop.example.com 41 | 42 | # Create a custom configuration directory 43 | mkdir -p ~/$ZERODROP_NAME/uploads/ 44 | 45 | # Copy configuration and binary 46 | cp $GOPATH/bin/zerodrop ~/$ZERODROP_NAME/zerodrop 47 | cp $GOPATH/src/github.com/oftn-oswg/zerodrop/config.yml ~/$ZERODROP_NAME/config.yml 48 | 49 | # EDIT config.yml to your liking. 50 | # 1) Set upload directory to /home/zerodrop/$ZERODROP_NAME/uploads/ 51 | # 2) Set db.source to /home/zerodrop/$ZERODROP_NAME/zerodrop.db 52 | # 3) ...and more... 53 | # REMEMBER to set up your secrets: 54 | # 1) authsecret: cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 55 | # 2) authdigest: echo -n YOUR_PASSWORD_HERE | sha256sum | cut -c -64 56 | vim ~/$ZERODROP_NAME/config.yml 57 | ``` 58 | 59 | ### As root 60 | 61 | ```sh 62 | # Copy systemd unit template 63 | cp $GOPATH/src/github.com/oftn-oswg/zerodrop/systemd/zerodrop@.service \ 64 | /etc/systemd/system 65 | 66 | # INSPECT this file for discrepancies 67 | vim /etc/system/system/zerodrop@.service 68 | 69 | # Reload daemon 70 | systemctl daemon-reload 71 | 72 | # Enable on startup 73 | systemctl enable zerodrop@$ZERODROP_NAME.service 74 | 75 | # Start 76 | systemctl start zerodrop@$ZERODROP_NAME.service 77 | ``` 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ΩF:∅ OSWG 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zerodrop 🕵️ 2 | 3 | A utility for private redirects and proxies that disappear after certain conditions are met. 4 | 5 | ## Live demo 6 | 7 | A live demo is available at this [dangerous.link](https://dangerous.link). 8 | 9 | ## Features 10 | 11 | * Web interface for creating resources 12 | * Create proxies and redirections for a given URL 13 | * Upload files or manually enter content in a textarea 14 | * Access control 15 | * Expire access to a resource after number of downloads 16 | * Block or allow access based on IP address 17 | * Block or allow access based on IP network 18 | * Block or allow access based on GeoIP location 19 | * Block or allow access based on hostname matching (w/ regex) 20 | * Publish "secret" pages with UUID generation 21 | * Self-destruct action which removes and kills running binary; configurable. 22 | 23 | ## Blacklist 24 | 25 | The blacklist syntax is similar to that of [gitignore][1]. An optional prefix `!` which negates the pattern can be used for whitelisting. 26 | 27 | ### Categories 28 | 29 | 1. Match All [`*`] (Useful for creating a whitelist) 30 | 2. Match IP [e.g. `203.0.113.6` or `2001:db8::68`] 31 | 3. Match IP Network [e.g.: `192.0.2.0/24` or `::1/128`] 32 | 4. Match Hostname [e.g. `crawl-66-249-66-1.googlebot.com`] 33 | 5. Match Hostname RegExp [e.g.: `~ .*\.cox\.net`] 34 | 6. Match Geofence [e.g.: `@ 39.377297 -74.451082 (7km)`] 35 | 7. Match [database][2] [e.g. `db datacenters` or `db tor`] 36 | 37 | ### Whitelist 38 | 39 | For example to only allow from local: 40 | 41 | ``` 42 | # This strange blacklist only allows access from localhost and google bots 43 | * 44 | ! ::1 # Allow localhost 45 | ! ~ .*\.google(bot)?\.com$ 46 | ``` 47 | 48 | ### Geofencing 49 | 50 | A `@` prefix is for targeted geofencing, i.e., `@ lat lng (optional radius)`. The default radius is 25m. For example to block Atlantic City: 51 | 52 | ``` 53 | @ 39.377297 -74.451082 (7km) 54 | ``` 55 | 56 | | Unit | Symbol | 57 | | --------- | ------ | 58 | | meter | m | 59 | | kilometer | km | 60 | | mile | mi | 61 | | feet | ft | 62 | 63 | ### Regular Expression 64 | 65 | A `~` prefix indicates a hostname regular expression match. 66 | 67 | ``` 68 | shady.com 69 | ~ (.*)\.shady\.com # Block subdomains of shady 70 | ``` 71 | 72 | ## Databases 73 | 74 | A rule that begins with "`db `" will be matched with a database by name, e.g., 75 | `!db tor` to whitelist Tor exit nodes. The database file must be specified in 76 | the config. 77 | 78 | ```yaml 79 | ipcat: 80 | cloudflare: cloudflare.csv 81 | datacenters: datacenters.csv 82 | tor: torexitnodes.csv 83 | ``` 84 | 85 | The format of the CSV file is specified by [ipcat][2] rules. 86 | 87 | 88 | [1]: https://git-scm.com/docs/gitignore 89 | [2]: https://github.com/oftn-oswg/ipcat 90 | -------------------------------------------------------------------------------- /admin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "crypto/subtle" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "html/template" 11 | "io" 12 | "io/ioutil" 13 | "log" 14 | "math/big" 15 | "mime" 16 | "net/http" 17 | "net/url" 18 | "os" 19 | "path" 20 | "path/filepath" 21 | "strings" 22 | "time" 23 | 24 | "github.com/dgrijalva/jwt-go" 25 | 26 | uuid "github.com/satori/go.uuid" 27 | 28 | "github.com/oftn-oswg/secureform" 29 | 30 | "gopkg.in/ezzarghili/recaptcha-go.v2" 31 | ) 32 | 33 | // AdminHandler serves the administration page, or asks for credentials if not 34 | // already authenticated. 35 | type AdminHandler struct { 36 | http.ServeMux 37 | 38 | App *ZerodropApp 39 | Templates *template.Template 40 | } 41 | 42 | // AdminClaims represents the claims of the JWT (JSON Web Token) 43 | type AdminClaims struct { 44 | Admin bool `json:"admin"` 45 | Token string `json:"token"` 46 | jwt.StandardClaims 47 | } 48 | 49 | // AdminPageData represents the data served to the admin templates. 50 | type AdminPageData struct { 51 | Error string 52 | Title string 53 | Claims *AdminClaims 54 | Config *ZerodropConfig 55 | 56 | All bool 57 | Entries []*ZerodropEntry 58 | } 59 | 60 | type AdminFormCredentials struct { 61 | Credentials string `form:"credentials"` 62 | } 63 | 64 | type EntrySource int 65 | 66 | const ( 67 | EntrySourceURL EntrySource = iota 68 | EntrySourceFile 69 | EntrySourceText 70 | ) 71 | 72 | func (s *EntrySource) Set(value string) error { 73 | switch value { 74 | case "url": 75 | *s = EntrySourceURL 76 | case "file": 77 | *s = EntrySourceFile 78 | case "text": 79 | *s = EntrySourceText 80 | default: 81 | return errors.New("Source type can be only url, file, or text") 82 | } 83 | return nil 84 | } 85 | 86 | type RequestURI string 87 | 88 | func (u *RequestURI) Set(value string) error { 89 | if value != "" { 90 | _, err := url.ParseRequestURI(value) 91 | if err != nil { 92 | return err 93 | } 94 | } 95 | 96 | *u = RequestURI(value) 97 | return nil 98 | } 99 | 100 | type EntryRedirect bool 101 | 102 | func (f *EntryRedirect) Set(value string) error { 103 | switch value { 104 | case "redirect": 105 | *f = true 106 | case "proxy": 107 | *f = false 108 | default: 109 | return errors.New("Invalid url type") 110 | } 111 | return nil 112 | } 113 | 114 | type ContentType string 115 | 116 | func (f *ContentType) Set(value string) error { 117 | if value != "" { 118 | _, _, err := mime.ParseMediaType(value) 119 | if err != nil { 120 | return err 121 | } 122 | } else { 123 | value = "text/plain" 124 | } 125 | *f = ContentType(value) 126 | return nil 127 | } 128 | 129 | type PageAction int 130 | 131 | const ( 132 | PageActionClear PageAction = iota 133 | PageActionDelete 134 | PageActionTrain 135 | ) 136 | 137 | func (s *PageAction) Set(value string) error { 138 | switch value { 139 | case "clear": 140 | *s = PageActionClear 141 | case "delete": 142 | *s = PageActionDelete 143 | case "train": 144 | *s = PageActionTrain 145 | default: 146 | return errors.New("Page action must be clear, delete, or train") 147 | } 148 | return nil 149 | } 150 | 151 | type AdminFormNewEntry struct { 152 | // Publish information 153 | Name string `form:"publish?max=512"` 154 | Token string `form:"token?max=64"` 155 | 156 | // Source information 157 | Source EntrySource `form:"source"` 158 | 159 | URL RequestURI `form:"url"` 160 | Redirect EntryRedirect `form:"url_type"` 161 | 162 | File secureform.File `form:"file"` 163 | FileType ContentType `form:"file_type"` 164 | 165 | Text string `form:"text"` 166 | TextType ContentType `form:"text_type"` 167 | 168 | // Access information 169 | AccessExpire bool `form:"access_expire"` 170 | AccessExpireCount uint `form:"access_expire_count"` 171 | AccessBlacklist string `form:"blacklist"` 172 | AccessRedirectOnDeny string `form:"access_redirect_on_deny?max=512"` 173 | 174 | // ReCaptcha 175 | ReCaptchaResponse string `form:"g-recaptcha-response"` 176 | } 177 | 178 | type AdminFormPageAction struct { 179 | Action PageAction `form:"action"` 180 | Name string `form:"name?max=512"` 181 | Token string `form:"token?max=64"` 182 | } 183 | 184 | // NewAdminHandler creates a new admin handler with the specified configuration 185 | // and loads the template files into cache. 186 | func NewAdminHandler(app *ZerodropApp) (*AdminHandler, error) { 187 | templateDirectory := "./templates" 188 | staticDirectory := "./static" 189 | 190 | handler := &AdminHandler{App: app} 191 | 192 | // Load templates 193 | templateFiles := []string{} 194 | files, err := ioutil.ReadDir(templateDirectory) 195 | if err != nil { 196 | return nil, err 197 | } 198 | for _, file := range files { 199 | filename := file.Name() 200 | if strings.HasSuffix(filename, ".tmpl") { 201 | templateFiles = append(templateFiles, 202 | path.Join(templateDirectory, filename)) 203 | } 204 | } 205 | 206 | handler.Templates, err = template.ParseFiles(templateFiles...) 207 | if err != nil { 208 | return nil, err 209 | } 210 | 211 | handler.Handle("/admin/static/", 212 | http.StripPrefix("/admin/static", http.FileServer(http.Dir(staticDirectory)))) 213 | handler.HandleFunc("/admin/login", handler.ServeLogin) 214 | handler.HandleFunc("/admin/logout", handler.ServeLogout) 215 | handler.HandleFunc("/admin/new", handler.ServeNew) 216 | handler.HandleFunc("/", handler.ServeList) 217 | 218 | return handler, nil 219 | } 220 | 221 | // verify returns any claims present in the request 222 | func (a *AdminHandler) verify(w http.ResponseWriter, r *http.Request) (*AdminClaims, error) { 223 | if cookie, err := r.Cookie("jwt"); err == nil { 224 | token, err := jwt.ParseWithClaims(cookie.Value, &AdminClaims{}, 225 | func(token *jwt.Token) (interface{}, error) { 226 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 227 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 228 | } 229 | return []byte(a.App.Config.AuthSecret), nil 230 | }) 231 | if claims, ok := token.Claims.(*AdminClaims); ok && token.Valid { 232 | return claims, nil 233 | } 234 | return nil, fmt.Errorf("Unknown error parsing validation cookie: %s", err.Error()) 235 | } 236 | 237 | token := make([]byte, 32) 238 | _, err := rand.Read(token) 239 | if err != nil { 240 | return nil, err 241 | } 242 | 243 | claims := &AdminClaims{ 244 | Admin: false, 245 | Token: hex.EncodeToString(token), 246 | } 247 | 248 | a.setClaims(w, claims) 249 | 250 | return claims, nil 251 | } 252 | 253 | // validate scans the request for credentials and generates a auth token 254 | func (a *AdminHandler) validate(w http.ResponseWriter, r *http.Request) error { 255 | form := AdminFormCredentials{} 256 | memory := int64(1 << 10) // 1 kB 257 | p := secureform.NewParser(memory, memory, memory) 258 | err := p.Parse(w, r, &form) 259 | if err != nil { 260 | return err 261 | } 262 | 263 | validDigestBytes, err := hex.DecodeString(a.App.Config.AuthDigest) 264 | if err != nil { 265 | return err 266 | } 267 | 268 | digest := sha256.Sum256([]byte(form.Credentials)) 269 | 270 | num, err := rand.Int(rand.Reader, big.NewInt(2000)) 271 | if err != nil { 272 | return err 273 | } 274 | 275 | time.Sleep(2*time.Second + time.Duration(num.Int64()-1000)*time.Millisecond) 276 | if subtle.ConstantTimeCompare(validDigestBytes, digest[:]) == 1 { 277 | 278 | // Authentication successful; set cookie 279 | claims := &AdminClaims{Admin: true} 280 | err := a.setClaims(w, claims) 281 | if err != nil { 282 | return err 283 | } 284 | 285 | return nil 286 | } 287 | 288 | return errors.New("Invalid password") 289 | } 290 | 291 | func (a *AdminHandler) setClaims(w http.ResponseWriter, claims *AdminClaims) error { 292 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 293 | tokenString, err := token.SignedString([]byte(a.App.Config.AuthSecret)) 294 | if err != nil { 295 | return err 296 | } 297 | 298 | http.SetCookie(w, &http.Cookie{ 299 | Name: "jwt", 300 | Value: tokenString, 301 | Path: "/", 302 | Expires: time.Now().Add(365 * 24 * time.Hour), // 1 year 303 | }) 304 | 305 | return nil 306 | } 307 | 308 | // ServeLogin renders the login page. 309 | func (a *AdminHandler) ServeLogin(w http.ResponseWriter, r *http.Request) { 310 | if r.Method == "POST" { 311 | ip := RealRemoteIP(r) 312 | err := a.validate(w, r) 313 | if err != nil { 314 | // Failed authentication 315 | log.Printf("Failed authentication by %s: %s", ip, err) 316 | http.Redirect(w, r, "/admin/login?err=1", 302) 317 | return 318 | } 319 | 320 | // Successful authentication 321 | log.Printf("Successful authentication by %s", ip) 322 | http.Redirect(w, r, "/", 302) 323 | return 324 | } 325 | 326 | claims, _ := a.verify(w, r) 327 | data := &AdminPageData{Title: "Zerodrop Login", Claims: claims, Config: a.App.Config} 328 | loginTmpl := a.Templates.Lookup("login.tmpl") 329 | err := loginTmpl.ExecuteTemplate(w, "login", data) 330 | if err != nil { 331 | log.Println(err) 332 | } 333 | } 334 | 335 | // ServeLogout renders the logout page. 336 | func (a *AdminHandler) ServeLogout(w http.ResponseWriter, r *http.Request) { 337 | http.SetCookie(w, &http.Cookie{ 338 | Name: "jwt", 339 | Path: "/", 340 | Value: "", 341 | Expires: time.Unix(0, 0), 342 | }) 343 | http.Redirect(w, r, "/", 302) 344 | } 345 | 346 | // ServeNew renders the new entry page. 347 | func (a *AdminHandler) ServeNew(w http.ResponseWriter, r *http.Request) { 348 | claims, _ := a.verify(w, r) 349 | 350 | if !a.App.Config.Public && !claims.Admin { 351 | // We are not a public instance 352 | // and the user is not logged in. 353 | 354 | http.Redirect(w, r, a.App.Config.Base+"admin/login", 302) 355 | return 356 | } 357 | 358 | if r.Method == "POST" { 359 | form := AdminFormNewEntry{} 360 | 361 | p := secureform.NewParser( 362 | int64(a.App.Config.UploadMaxSize), 363 | int64(a.App.Config.UploadMaxSize), 364 | 0) 365 | err := p.Parse(w, r, &form) 366 | 367 | if err != nil { 368 | http.Error(w, err.Error(), 500) 369 | return 370 | } 371 | 372 | // Test ReCaptcha 373 | if a.App.Config.Recaptcha.SiteKey != "" { 374 | captcha, err := recaptcha.NewReCAPTCHA(a.App.Config.Recaptcha.SecretKey) 375 | if err != nil { 376 | log.Println(err) 377 | http.Error(w, err.Error(), 500) 378 | return 379 | } 380 | ok, err := captcha.Verify(form.ReCaptchaResponse, RealRemoteIP(r).String()) 381 | if err != nil { 382 | http.Error(w, err.Error(), 500) 383 | return 384 | } 385 | if !ok { 386 | http.Error(w, "You might be a robot. Rust in peace.", 500) 387 | return 388 | } 389 | } 390 | 391 | entry := &ZerodropEntry{Creation: time.Now()} 392 | 393 | // Publish information 394 | entry.Name = form.Name 395 | if entry.Name == "" { 396 | id := uuid.NewV4() 397 | entry.Name = id.String() 398 | } 399 | 400 | if a.App.Config.disallowRegexp != nil { 401 | // Check entry name against Disallow configuration 402 | if a.App.Config.disallowRegexp.MatchString(entry.Name) { 403 | http.Error(w, "Entry name has been disallowed", 500) 404 | return 405 | } 406 | } 407 | 408 | // Source information 409 | switch form.Source { 410 | case EntrySourceURL: 411 | entry.URL = string(form.URL) 412 | entry.Redirect = bool(form.Redirect) 413 | 414 | case EntrySourceFile: 415 | file, err := form.File.Open() 416 | if err != nil { 417 | http.Error(w, err.Error(), 500) 418 | return 419 | } 420 | defer file.Close() 421 | 422 | filename := url.PathEscape(entry.Name) 423 | fullpath := filepath.Join(a.App.Config.UploadDirectory, filename) 424 | 425 | perms := os.FileMode(a.App.Config.UploadPermissions) 426 | out, err := os.OpenFile(fullpath, os.O_WRONLY|os.O_CREATE, perms) 427 | if err != nil { 428 | http.Error(w, err.Error(), 500) 429 | return 430 | } 431 | defer out.Close() 432 | 433 | _, err = io.Copy(out, file) 434 | if err != nil { 435 | http.Error(w, err.Error(), 500) 436 | return 437 | } 438 | 439 | entry.Filename = filename 440 | entry.ContentType = string(form.FileType) 441 | 442 | case EntrySourceText: 443 | filename := url.PathEscape(entry.Name) 444 | fullpath := filepath.Join(a.App.Config.UploadDirectory, filename) 445 | 446 | perms := os.FileMode(a.App.Config.UploadPermissions) 447 | out, err := os.OpenFile(fullpath, os.O_WRONLY|os.O_CREATE, perms) 448 | if err != nil { 449 | http.Error(w, err.Error(), 500) 450 | return 451 | } 452 | defer out.Close() 453 | 454 | _, err = io.WriteString(out, form.Text) 455 | if err != nil { 456 | http.Error(w, err.Error(), 500) 457 | return 458 | } 459 | 460 | entry.Filename = filename 461 | entry.ContentType = string(form.TextType) 462 | } 463 | 464 | // Access information 465 | entry.AccessExpire = form.AccessExpire 466 | entry.AccessExpireCount = int(form.AccessExpireCount) 467 | entry.AccessBlacklist = ParseBlacklist(form.AccessBlacklist, a.App.Config.IPCat) 468 | entry.AccessRedirectOnDeny = strings.TrimSpace(form.AccessRedirectOnDeny) 469 | 470 | if err := a.App.DB.Update(entry, claims); err != nil { 471 | log.Printf("Error creating entry %s: %s", entry.Name, err) 472 | } else { 473 | log.Printf("Created entry %s with %s", entry, RealRemoteIP(r)) 474 | } 475 | 476 | redirectPage := a.App.Config.Base + "admin/my" 477 | if claims.Admin { 478 | redirectPage = a.App.Config.Base 479 | } 480 | http.Redirect(w, r, redirectPage, 302) 481 | return 482 | } 483 | 484 | data := &AdminPageData{Title: "Zerodrop Admin :: New", Claims: claims, Config: a.App.Config} 485 | loginTmpl := a.Templates.Lookup("new.tmpl") 486 | err := loginTmpl.ExecuteTemplate(w, "new", data) 487 | if err != nil { 488 | log.Println(err) 489 | } 490 | } 491 | 492 | // ServeList serves the entry list. 493 | func (a *AdminHandler) ServeList(w http.ResponseWriter, r *http.Request) { 494 | claims, _ := a.verify(w, r) 495 | 496 | if !a.App.Config.Public && !claims.Admin { 497 | // We are not a public instance 498 | // and the user is not logged in. 499 | 500 | http.Redirect(w, r, a.App.Config.Base+"admin/login", 302) 501 | return 502 | } 503 | 504 | data := &AdminPageData{Title: "Zerodrop Admin", Claims: claims, Config: a.App.Config} 505 | 506 | all := true 507 | if strings.HasSuffix(r.RequestURI, "/my") { 508 | all = false 509 | } 510 | 511 | if r.Method == "POST" { 512 | form := AdminFormPageAction{} 513 | mem := int64(1 << 10) // 1 kB 514 | p := secureform.NewParser(mem, mem, mem) 515 | err := p.Parse(w, r, &form) 516 | if err != nil { 517 | http.Error(w, err.Error(), 500) 518 | return 519 | } 520 | 521 | switch form.Action { 522 | 523 | case PageActionTrain: 524 | entry, err := a.App.DB.Get(form.Name) 525 | if err != nil { 526 | log.Println(err) 527 | } else { 528 | entry.SetTraining(!entry.AccessTrain) 529 | if err := a.App.DB.Update(entry, claims); err != nil { 530 | log.Println(err) 531 | } 532 | } 533 | 534 | case PageActionDelete: 535 | if form.Name != "" { 536 | err := a.App.DB.Remove(form.Name, claims) 537 | if err != nil { 538 | log.Println(err) 539 | } else { 540 | log.Printf("Removed entry %q with IP %s", form.Name, RealRemoteIP(r)) 541 | } 542 | } 543 | 544 | case PageActionClear: 545 | err := a.App.DB.Clear(claims) 546 | if err != nil { 547 | log.Println(err) 548 | } else { 549 | log.Printf("Cleared all entries with token %q from %s", form.Token, RealRemoteIP(r)) 550 | } 551 | 552 | } 553 | 554 | http.Redirect(w, r, r.RequestURI, 302) 555 | return 556 | } 557 | 558 | token := "" 559 | if !all { 560 | token = claims.Token 561 | } 562 | entries, err := a.App.DB.List(token) 563 | if err != nil { 564 | log.Println(err) 565 | } 566 | 567 | data.All = all 568 | data.Entries = entries 569 | 570 | interfaceTmpl := a.Templates.Lookup("entries.tmpl") 571 | err = interfaceTmpl.ExecuteTemplate(w, "entries", data) 572 | if err != nil { 573 | log.Println(err) 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /blacklist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/oftn-oswg/ipcat" 12 | "github.com/oschwald/geoip2-golang" 13 | ) 14 | 15 | // BlacklistContext is a structure used to contain the external data 16 | // used to categorize IP addresses needed for specific rules, like 17 | // the geolocation database used for geofencing or the ipcat database. 18 | type BlacklistContext struct { 19 | GeoDB *geoip2.Reader 20 | Databases map[string]*ipcat.IntervalSet 21 | } 22 | 23 | // BlacklistRule is a structure that represents a rule or comment as part 24 | // of a blacklist. 25 | type BlacklistRule struct { 26 | Comment string 27 | Negation bool 28 | All bool 29 | Network *net.IPNet 30 | IP net.IP 31 | Hostname string 32 | Regexp string 33 | Geofence *Geofence 34 | Database string 35 | } 36 | 37 | func (i BlacklistRule) String() (value string) { 38 | if i.Negation { 39 | value += "!" 40 | } 41 | 42 | if i.All { 43 | value += "*" 44 | if i.Comment != "" { 45 | value += " # " + i.Comment 46 | } 47 | return 48 | } 49 | 50 | if i.Network != nil { 51 | value += i.Network.String() 52 | if i.Comment != "" { 53 | value += " # " + i.Comment 54 | } 55 | return 56 | } 57 | 58 | if i.IP != nil { 59 | value += i.IP.String() 60 | if i.Comment != "" { 61 | value += " # " + i.Comment 62 | } 63 | return 64 | } 65 | 66 | if i.Hostname != "" { 67 | value += i.Hostname 68 | if i.Comment != "" { 69 | value += " # " + i.Comment 70 | } 71 | return 72 | } 73 | 74 | if i.Regexp != "" { 75 | value += "~" 76 | value += i.Regexp 77 | if i.Comment != "" { 78 | value += " # " + i.Comment 79 | } 80 | return 81 | } 82 | 83 | if i.Geofence != nil { 84 | value += "@ " + 85 | strconv.FormatFloat(i.Geofence.Latitude, 'f', -1, 64) + ", " + 86 | strconv.FormatFloat(i.Geofence.Longitude, 'f', -1, 64) + " (" + 87 | strconv.FormatFloat(i.Geofence.Radius, 'f', -1, 64) + "m)" 88 | if i.Comment != "" { 89 | value += " # " + i.Comment 90 | } 91 | return 92 | } 93 | 94 | if i.Database != "" { 95 | value += "db " + i.Database 96 | if i.Comment != "" { 97 | value += " # " + i.Comment 98 | } 99 | return 100 | } 101 | 102 | if i.Comment != "" { 103 | value += "# " + i.Comment 104 | } 105 | 106 | return 107 | } 108 | 109 | // Blacklist is a list of BlacklistRules 110 | type Blacklist struct { 111 | List []*BlacklistRule 112 | } 113 | 114 | func (b Blacklist) String() string { 115 | itemCount := 0 116 | 117 | // Stringify items 118 | items := make([]string, len(b.List)+1) 119 | for index, item := range b.List { 120 | if item.All || item.Network != nil || item.Geofence != nil || 121 | item.Database != "" || item.Hostname != "" || item.IP != nil || 122 | item.Regexp != "" { 123 | itemCount++ 124 | } 125 | items[index+1] = item.String() 126 | } 127 | 128 | // Blacklist comment header 129 | switch itemCount { 130 | case 0: 131 | items[0] = "# Empty blacklist" 132 | case 1: 133 | items[0] = "# Blacklist with 1 item" 134 | default: 135 | items[0] = "# Blacklist with " + strconv.Itoa(itemCount) + " items" 136 | } 137 | 138 | return strings.Join(items, "\n") 139 | } 140 | 141 | var geofenceRegexp = regexp.MustCompile(`^([-+]?[0-9]*\.?[0-9]+)[^-+0-9]+([-+]?[0-9]*\.?[0-9]+)(?:[^0-9]+([0-9]*\.?[0-9]+)([A-Za-z]*)[^0-9]*)?$`) 142 | var geofenceUnits = map[string]float64{ 143 | "": 1.0, 144 | "m": 1.0, 145 | "km": 1000.0, 146 | "mi": 1609.0, 147 | "ft": 1609.0 / 5280.0, 148 | } 149 | 150 | // ParseBlacklist parses a text blacklist and returns a Blacklist object. 151 | func ParseBlacklist(text string, dbconfig map[string]string) Blacklist { 152 | lines := strings.Split(text, "\n") 153 | blacklist := Blacklist{List: []*BlacklistRule{}} 154 | 155 | for _, line := range lines { 156 | item := &BlacklistRule{} 157 | 158 | // A line with # serves as a comment. 159 | if commentStart := strings.IndexByte(line, '#'); commentStart >= 0 { 160 | item.Comment = strings.TrimSpace(line[commentStart+1:]) 161 | line = line[:commentStart] 162 | } 163 | 164 | // A blank line matches no files, 165 | // so it can serve as a separator for readability. 166 | line = strings.TrimSpace(line) 167 | if line == "" { 168 | if item.Comment != "" { 169 | blacklist.Add(item) 170 | } 171 | continue 172 | } 173 | 174 | // An optional prefix "!" which negates the pattern; 175 | // any matching address/host excluded by a previous pattern 176 | // will become included again. 177 | if line[0] == '!' { 178 | item.Negation = true 179 | line = strings.TrimSpace(line[1:]) 180 | } 181 | 182 | // A line with only "*" matches everything, 183 | // allowing the creation of a whitelist. 184 | if line == "*" { 185 | item.All = true 186 | blacklist.Add(item) 187 | continue 188 | } 189 | 190 | // Database query match 191 | if line[:3] == "db " { 192 | db := strings.ToLower(strings.TrimSpace(line[3:])) 193 | if _, ok := dbconfig[db]; !ok { 194 | item.Comment = fmt.Sprintf("Error: %s: No database specified named %q", line, db) 195 | blacklist.Add(item) 196 | continue 197 | } 198 | item.Database = db 199 | blacklist.Add(item) 200 | continue 201 | } 202 | 203 | switch line[0] { 204 | case '@': 205 | // An optional prefix "@" indicates a geofencing target. 206 | var lat, lng, radius float64 = 0, 0, 25 207 | 208 | line = strings.TrimSpace(line[1:]) 209 | matches := geofenceRegexp.FindStringSubmatch(line) 210 | 211 | if len(matches) == 5 { 212 | var err error 213 | 214 | latString, lngString, radiusString, units := 215 | matches[1], matches[2], matches[3], strings.ToLower(matches[4]) 216 | 217 | // Parse latitude 218 | if lat, err = strconv.ParseFloat(latString, 64); err != nil { 219 | item.Comment = fmt.Sprintf( 220 | "Error: %s: could not parse latitude: %s", 221 | line, err.Error()) 222 | blacklist.Add(item) 223 | continue 224 | } 225 | 226 | // Parse longitude 227 | if lng, err = strconv.ParseFloat(lngString, 64); err != nil { 228 | item.Comment = fmt.Sprintf( 229 | "Error: %s: could not parse longitude: %s", 230 | line, err.Error()) 231 | blacklist.Add(item) 232 | continue 233 | } 234 | 235 | // Parse optional radius 236 | if radiusString != "" { 237 | if radius, err = strconv.ParseFloat(radiusString, 64); err != nil { 238 | item.Comment = fmt.Sprintf( 239 | "Error: %s: could not parse radius: %s", 240 | line, err.Error()) 241 | blacklist.Add(item) 242 | continue 243 | } 244 | } 245 | 246 | // Parse units 247 | factor, ok := geofenceUnits[units] 248 | if !ok { 249 | item.Comment = fmt.Sprintf( 250 | "Error: %s: invalid radial units: %s", 251 | line, strconv.Quote(units)) 252 | blacklist.Add(item) 253 | continue 254 | } 255 | radius *= factor 256 | 257 | } else { 258 | item.Comment = fmt.Sprintf( 259 | "Error: %s: invalid format: must be , ()?", 260 | line) 261 | blacklist.Add(item) 262 | continue 263 | } 264 | 265 | item.Geofence = &Geofence{ 266 | Latitude: lat, 267 | Longitude: lng, 268 | Radius: radius, 269 | } 270 | blacklist.Add(item) 271 | continue 272 | 273 | case '~': 274 | // An optional prefix "~" indicates a hostname regular expression match. 275 | line = strings.TrimSpace(line[1:]) 276 | _, err := regexp.Compile(line) 277 | if err != nil { 278 | item.Comment = fmt.Sprintf( 279 | "Error: %s: malformed regular expression: %s", 280 | line, err.Error()) 281 | blacklist.Add(item) 282 | continue 283 | } 284 | 285 | item.Regexp = line 286 | blacklist.Add(item) 287 | continue 288 | } 289 | 290 | // If a CIDR notation is given, then parse that as an IP network. 291 | _, network, err := net.ParseCIDR(line) 292 | if err == nil { 293 | item.Network = network 294 | blacklist.Add(item) 295 | continue 296 | } 297 | 298 | // If an IP address is given, parse as unique IP. 299 | if ip := net.ParseIP(line); ip != nil { 300 | item.IP = ip 301 | blacklist.Add(item) 302 | continue 303 | } 304 | 305 | // Otherwise, treat the pattern as a hostname. 306 | item.Hostname = strings.ToLower(line) 307 | blacklist.Add(item) 308 | } 309 | 310 | return blacklist 311 | } 312 | 313 | // Add appends a BlacklistRule to the Blacklist. 314 | func (b *Blacklist) Add(item *BlacklistRule) { 315 | b.List = append(b.List, item) 316 | } 317 | 318 | // Allow decides whether the Blacklist permits the selected IP address. 319 | func (b *Blacklist) Allow(ctx *BlacklistContext, ip net.IP) bool { 320 | allow := true 321 | 322 | user := (*Geofence)(nil) 323 | 324 | for _, item := range b.List { 325 | match := false 326 | 327 | if item.All { 328 | // Wildcard 329 | match = true 330 | 331 | } else if item.Network != nil { 332 | // IP Network 333 | match = item.Network.Contains(ip) 334 | 335 | } else if item.IP != nil { 336 | // IP Address 337 | match = item.IP.Equal(ip) 338 | 339 | } else if item.Hostname != "" { 340 | // Hostname 341 | addrs, err := net.LookupIP(item.Hostname) 342 | if err != nil { 343 | for _, addr := range addrs { 344 | if addr.Equal(ip) { 345 | match = true 346 | break 347 | } 348 | } 349 | } 350 | 351 | names, err := net.LookupAddr(ip.String()) 352 | if err != nil { 353 | for _, name := range names { 354 | name = strings.ToLower(name) 355 | if name == item.Hostname { 356 | match = true 357 | break 358 | } 359 | } 360 | } 361 | 362 | } else if item.Regexp != "" { 363 | // Regular Expression 364 | regex, err := regexp.Compile(item.Regexp) 365 | if err != nil { 366 | log.Printf("Error compiling regular expression: %s", err) 367 | } 368 | 369 | names, err := net.LookupAddr(ip.String()) 370 | if err != nil { 371 | for _, name := range names { 372 | name = strings.ToLower(name) 373 | if regex.Match([]byte(name)) { 374 | match = true 375 | break 376 | } 377 | } 378 | } 379 | } else if item.Geofence != nil { 380 | if ctx.GeoDB == nil { 381 | log.Println("Denying access by geofence rule error: no database provided") 382 | return false 383 | } 384 | 385 | if user == nil { 386 | record, err := ctx.GeoDB.City(ip) 387 | if err != nil { 388 | log.Printf("Denying access by geofence rule error: %s", err.Error()) 389 | return false 390 | } 391 | user = &Geofence{ 392 | Latitude: record.Location.Latitude, 393 | Longitude: record.Location.Longitude, 394 | Radius: float64(record.Location.AccuracyRadius) * 1000.0, // Convert km to m 395 | } 396 | } 397 | 398 | bounds := item.Geofence 399 | boundsIntersect := bounds.Intersection(user) 400 | if item.Negation { 401 | // Whitelist if user is completely contained within bounds 402 | match = boundsIntersect&IsSuperset != 0 403 | } else { 404 | // Blacklist if user intersects at all with bounds 405 | match = !(boundsIntersect&IsDisjoint != 0) 406 | } 407 | } else if item.Database != "" { 408 | db, ok := ctx.Databases[item.Database] 409 | if !ok { 410 | log.Printf("Denying access by db rule error: database %q not provided", item.Database) 411 | return false 412 | } 413 | 414 | interval, err := db.Contains(ip.String()) 415 | if err != nil { 416 | log.Printf("Denying access by db rule error: %s", err.Error()) 417 | return false 418 | } 419 | 420 | match = interval != nil 421 | } 422 | 423 | // TODO: Allow early termination based on negation flags 424 | if match { 425 | allow = item.Negation 426 | } 427 | } 428 | 429 | return allow 430 | } 431 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # Unix or TCP socket to listen for web requests 2 | listen: 8080 3 | # Group of the Unix socket 4 | group: www-data 5 | 6 | # Root of the publicly facing web server to prepend links 7 | base: / 8 | # A secret used to sign authorization tokens 9 | authsecret: "ggVUtPQdIL3kuMSeHQgn7PW9nv3XuJBp" 10 | # The SHA-256 hash of the password to gain admin access 11 | authdigest: 11a55ac5de2beb9146e01386dd978a13bb9b99388f5eb52e37f69a32e3d5f11e 12 | 13 | # The MaxMind GeoDB used for geofencing 14 | geodb: GeoLite2-City.mmdb 15 | # The databases to include for categorical blacklisting 16 | ipcat: 17 | datacenters: ../ipcat/datacenters.csv 18 | tor: ../ipcat/tor.csv 19 | 20 | # The directory to upload files to 21 | uploaddirectory: ./uploads/ 22 | # The permissions to set for file uploads 23 | uploadpermissions: 0600 24 | # The maximum form and file upload size in bytes 25 | uploadmaxsize: 1000000 26 | 27 | # Boolean value inidicating whether everyone has can manage their own entries 28 | public: false 29 | # Regular expression to match entry name against to disallow 30 | disallow: ^(admin|.well-known|sitemap.xml) 31 | 32 | selfdestruct: 33 | # Whether to allow self-destruct 34 | enable: false 35 | # Entry name to use to initiate a self-destruct 36 | keyword: \U0001f4a3 37 | # Besides the uploads directory and binary, 38 | # The list of files to delete upon self-destruct 39 | files: [] 40 | 41 | # The number of levels of internal redirects to allow for the Redirect On Deny 42 | # feature. This is to prevent infinite loops. 43 | redirectlevels: 128 44 | 45 | recaptcha: 46 | # Set to the fields provided by reCAPTCHA v2 "I am not a robot" widget 47 | sitekey: "" 48 | secretkey: "" 49 | 50 | # Database connection information 51 | db: 52 | driver: sqlite3 53 | source: zerodrop.db 54 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "encoding/gob" 7 | "errors" 8 | "strconv" 9 | "time" 10 | 11 | _ "github.com/go-sql-driver/mysql" 12 | _ "github.com/lib/pq" 13 | _ "github.com/mattn/go-sqlite3" 14 | ) 15 | 16 | var ErrNotAuthorized = errors.New("Not authorized") 17 | 18 | // ZerodropEntry is a page entry. 19 | type ZerodropEntry struct { 20 | Name string // The request URI used to access this entry 21 | URL string // The URL that this entry references 22 | Filename string // The location of the file in the uploads directory 23 | ContentType string // The MIME type to serve as Content-Type header 24 | Redirect bool // Indicates whether to redirect instead of proxy 25 | Creation time.Time // The time this entry was created 26 | AccessRedirectOnDeny string // Entry to redirect to if entry is blacklisted or expired 27 | AccessBlacklist Blacklist // Blacklist 28 | AccessBlacklistCount int // Number of requests that have been caught by the blacklist 29 | AccessExpire bool // Indicates whether to expire after finite access 30 | AccessExpireCount int // The number of requests on this entry before expiry 31 | AccessCount int // The number of times this has been accessed 32 | AccessTrain bool // Whether training is active 33 | } 34 | 35 | // ZerodropDB represents a database connection. 36 | // TODO: Use a persistent backend. 37 | type ZerodropDB struct { 38 | *sql.DB 39 | 40 | // Accessors 41 | GetStmt *sql.Stmt 42 | ListStmt *sql.Stmt 43 | ListTokenStmt *sql.Stmt 44 | 45 | // Restricted Mutators 46 | UpdateCheckTokenStmt *sql.Stmt 47 | DeleteStmt *sql.Stmt 48 | ClearStmt *sql.Stmt 49 | 50 | // Admin Mutators 51 | AdminUpdateStmt *sql.Stmt 52 | AdminDeleteStmt *sql.Stmt 53 | AdminClearStmt *sql.Stmt 54 | } 55 | 56 | // Connect opens a connection to the backend. 57 | func (d *ZerodropDB) Connect(driver, source string) error { 58 | db, err := sql.Open(driver, source) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | gob.Register(&ZerodropEntry{}) 64 | 65 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS entries ( 66 | name TEXT PRIMARY KEY NOT NULL, 67 | token TEXT NOT NULL, 68 | creation INTEGER NOT NULL, 69 | gob BLOB NOT NULL 70 | )`); err != nil { 71 | return err 72 | } 73 | 74 | // Accessors 75 | d.GetStmt, err = db.Prepare( 76 | `SELECT gob FROM entries WHERE name = ?`) 77 | if err != nil { 78 | return err 79 | } 80 | d.ListStmt, err = db.Prepare( 81 | `SELECT gob FROM entries ORDER BY creation DESC`) 82 | if err != nil { 83 | return err 84 | } 85 | d.ListTokenStmt, err = db.Prepare( 86 | `SELECT gob FROM entries WHERE token = ? ORDER BY creation DESC`) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // Restricted Mutators 92 | d.UpdateCheckTokenStmt, err = db.Prepare( 93 | `SELECT token FROM entries WHERE name = ?`) 94 | if err != nil { 95 | return err 96 | } 97 | d.DeleteStmt, err = db.Prepare( 98 | `DELETE FROM entries WHERE name = ? AND token = ?`) 99 | if err != nil { 100 | return err 101 | } 102 | d.ClearStmt, err = db.Prepare( 103 | `DELETE FROM entries WHERE token = ?`) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // Admin Mutators 109 | d.AdminUpdateStmt, err = db.Prepare( 110 | `REPLACE INTO entries (name, token, creation, gob) VALUES (?, ?, ?, ?)`) 111 | if err != nil { 112 | return err 113 | } 114 | d.AdminDeleteStmt, err = db.Prepare( 115 | `DELETE FROM entries WHERE name = ?`) 116 | if err != nil { 117 | return err 118 | } 119 | d.AdminClearStmt, err = db.Prepare( 120 | `DELETE FROM entries`) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | d.DB = db 126 | return nil 127 | } 128 | 129 | // Get returns the entry with the specified name. 130 | func (d *ZerodropDB) Get(name string) (*ZerodropEntry, error) { 131 | var data []byte 132 | if err := d.GetStmt.QueryRow(name).Scan(&data); err != nil { 133 | return nil, err 134 | } 135 | 136 | var entry *ZerodropEntry 137 | dec := gob.NewDecoder(bytes.NewReader(data)) 138 | if err := dec.Decode(&entry); err != nil { 139 | return nil, err 140 | } 141 | 142 | return entry, nil 143 | } 144 | 145 | // List returns a slice of all entries sorted by creation time, 146 | // with the most recent first. 147 | func (d *ZerodropDB) List(token string) ([]*ZerodropEntry, error) { 148 | list := []*ZerodropEntry{} 149 | 150 | var err error 151 | var rows *sql.Rows 152 | 153 | if token == "" { 154 | rows, err = d.ListStmt.Query() 155 | } else { 156 | rows, err = d.ListTokenStmt.Query(token) 157 | } 158 | if err != nil { 159 | return nil, err 160 | } 161 | defer rows.Close() 162 | 163 | for rows.Next() { 164 | var data []byte 165 | if err := rows.Scan(&data); err != nil { 166 | return nil, err 167 | } 168 | 169 | var entry *ZerodropEntry 170 | dec := gob.NewDecoder(bytes.NewReader(data)) 171 | if err := dec.Decode(&entry); err != nil { 172 | return nil, err 173 | } 174 | 175 | list = append(list, entry) 176 | } 177 | 178 | if err := rows.Err(); err != nil { 179 | return nil, err 180 | } 181 | 182 | return list, nil 183 | } 184 | 185 | // Update adds an entry to the database. 186 | func (d *ZerodropDB) Update(entry *ZerodropEntry, claims *AdminClaims) error { 187 | var token string 188 | 189 | err := d.UpdateCheckTokenStmt.QueryRow(entry.Name).Scan(&token) 190 | if err != nil { 191 | // The entry does not exist. 192 | token = claims.Token 193 | } else if !claims.Admin && token != claims.Token { 194 | // The entry exists and the tokens do not match. 195 | return ErrNotAuthorized 196 | } 197 | 198 | var buffer bytes.Buffer 199 | enc := gob.NewEncoder(&buffer) 200 | if err := enc.Encode(entry); err != nil { 201 | return err 202 | } 203 | 204 | if _, err := d.AdminUpdateStmt.Exec(entry.Name, token, entry.Creation.Unix(), buffer.Bytes()); err != nil { 205 | return err 206 | } 207 | 208 | return nil 209 | } 210 | 211 | // Remove removes an entry from the database with the specified token. 212 | func (d *ZerodropDB) Remove(name string, claims *AdminClaims) error { 213 | if claims.Admin { 214 | if _, err := d.AdminDeleteStmt.Exec(name); err != nil { 215 | return err 216 | } 217 | return nil 218 | } 219 | 220 | if _, err := d.DeleteStmt.Exec(name, claims.Token); err != nil { 221 | return err 222 | } 223 | 224 | return nil 225 | } 226 | 227 | // Clear resets the database by removing all entries with the specified token. 228 | func (d *ZerodropDB) Clear(claims *AdminClaims) error { 229 | if claims.Admin { 230 | if _, err := d.AdminClearStmt.Exec(); err != nil { 231 | return err 232 | } 233 | 234 | return nil 235 | } 236 | 237 | if _, err := d.ClearStmt.Exec(claims.Token); err != nil { 238 | return err 239 | } 240 | 241 | return nil 242 | } 243 | 244 | // IsExpired returns true if the entry is expired 245 | func (e *ZerodropEntry) IsExpired() bool { 246 | return e.AccessExpire && (e.AccessCount >= e.AccessExpireCount) 247 | } 248 | 249 | // SetTraining sets the AccessTrain flag 250 | func (e *ZerodropEntry) SetTraining(train bool) { 251 | e.AccessTrain = train 252 | } 253 | 254 | // Access increases the access count for an entry. 255 | func (e *ZerodropEntry) Access() { 256 | e.AccessCount++ 257 | } 258 | 259 | func (e *ZerodropEntry) String() string { 260 | return strconv.Quote(e.Name) 261 | } 262 | -------------------------------------------------------------------------------- /geofence.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/kellydunn/golang-geo" 5 | ) 6 | 7 | // Geofence represents a point on the Earth with an accuracy radius in meters. 8 | type Geofence struct { 9 | Latitude, Longitude, Radius float64 10 | } 11 | 12 | // SetIntersection is a description of the relationship between two sets. 13 | type SetIntersection uint 14 | 15 | const ( 16 | // IsDisjoint means that the two sets have no common elements. 17 | IsDisjoint SetIntersection = 1 << iota 18 | 19 | // IsSubset means the first set is a subset of the second. 20 | IsSubset 21 | 22 | // IsSuperset means the second set is a subset of the first. 23 | IsSuperset 24 | ) 25 | 26 | // Intersection describes the relationship between two geofences 27 | func (mi *Geofence) Intersection(tu *Geofence) (i SetIntersection) { 28 | miPoint := geo.NewPoint(mi.Latitude, mi.Longitude) 29 | tuPoint := geo.NewPoint(tu.Latitude, tu.Longitude) 30 | distance := miPoint.GreatCircleDistance(tuPoint) * 1000 31 | 32 | radiusSum := mi.Radius + tu.Radius 33 | radiusDiff := mi.Radius - tu.Radius 34 | 35 | if distance-radiusSum > 0 { 36 | i = IsDisjoint 37 | return 38 | } 39 | 40 | if -distance+radiusDiff >= 0 { 41 | i |= IsSuperset 42 | } 43 | 44 | if -distance-radiusDiff >= 0 { 45 | i |= IsSubset 46 | } 47 | 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /geofence_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestIntersection(t *testing.T) { 6 | tests := []struct { 7 | Mi Geofence 8 | Tu Geofence 9 | Result SetIntersection 10 | }{ 11 | {Geofence{36.1699, -115.1398, 1000.0}, Geofence{36.1699, -115.1398, 10.0}, IsSuperset}, 12 | {Geofence{36.1699, -115.1398, 10.0}, Geofence{36.1699, -115.1398, 1000.0}, IsSubset}, 13 | {Geofence{36.1699, -115.1398, 10.0}, Geofence{37.7749, -122.4194, 1000.0}, IsDisjoint}, 14 | {Geofence{36.1699, -115.1398, 10.0}, Geofence{36.1699, -115.1398, 10.0}, IsSubset | IsSuperset}, 15 | {Geofence{36.1699, -115.13983, 100.0}, Geofence{36.1699, -115.1398, 100.0}, 0}, 16 | } 17 | 18 | for _, test := range tests { 19 | got := test.Mi.Intersection(&test.Tu) 20 | want := test.Result 21 | if want != got { 22 | t.Errorf("With %s and %s: expected intersection code %b, got %b", 23 | BlacklistRule{Geofence: &test.Mi}, BlacklistRule{Geofence: &test.Tu}, want, got) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/oftn-oswg/zerodrop 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/go-sql-driver/mysql v1.8.1 8 | github.com/jinzhu/configor v1.2.2 9 | github.com/kellydunn/golang-geo v0.7.0 10 | github.com/lib/pq v1.10.9 11 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 12 | github.com/oftn-oswg/ipcat v0.0.0-20180517005950-a8ecd7ee10a8 13 | github.com/oftn-oswg/secureform v0.0.0-20180523042656-55f6e392476b 14 | github.com/oftn-oswg/socket v0.0.0-20180521040844-778302196f1e 15 | github.com/oschwald/geoip2-golang v1.11.0 16 | github.com/satori/go.uuid v1.2.0 17 | gopkg.in/ezzarghili/recaptcha-go.v2 v2.0.2 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/BurntSushi/toml v1.2.0 // indirect 23 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect 24 | github.com/kylelemons/go-gypsy v1.0.0 // indirect 25 | github.com/oschwald/maxminddb-golang v1.13.0 // indirect 26 | github.com/ziutek/mymysql v1.5.4 // indirect 27 | golang.org/x/sys v0.20.0 // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= 4 | github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 8 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 9 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= 10 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 11 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 12 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 13 | github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= 14 | github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= 15 | github.com/kellydunn/golang-geo v0.7.0 h1:A5j0/BvNgGwY6Yb6inXQxzYwlPHc6WVZR+MrarZYNNg= 16 | github.com/kellydunn/golang-geo v0.7.0/go.mod h1:YYlQPJ+DPEzrHx8kT3oPHC/NjyvCCXE+IuKGKdrjrcU= 17 | github.com/kylelemons/go-gypsy v1.0.0 h1:7/wQ7A3UL1bnqRMnZ6T8cwCOArfZCxFmb1iTxaOOo1s= 18 | github.com/kylelemons/go-gypsy v1.0.0/go.mod h1:chkXM0zjdpXOiqkCW1XcCHDfjfk14PH2KKkQWxfJUcU= 19 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 20 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 21 | github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= 22 | github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 23 | github.com/oftn-oswg/ipcat v0.0.0-20180517005950-a8ecd7ee10a8 h1:SMdJ1hFbuwKPr5rY+FPiHHw3jLjIeoMQwi7KRmeawgc= 24 | github.com/oftn-oswg/ipcat v0.0.0-20180517005950-a8ecd7ee10a8/go.mod h1:HNc7g7k8QSKRzma+sa/3tF9f7492J8EIlMalerGFUao= 25 | github.com/oftn-oswg/secureform v0.0.0-20180523042656-55f6e392476b h1:wMzXC3AJwR5XLP7KcmUz/8RfHxtIQ1hhS5CyxH5NbKY= 26 | github.com/oftn-oswg/secureform v0.0.0-20180523042656-55f6e392476b/go.mod h1:zGCLjkQ59yD6w37Yiw5Aj46QmDy9g8hswu5yGcP3EBQ= 27 | github.com/oftn-oswg/socket v0.0.0-20180521040844-778302196f1e h1:Zo5zaqMsp5ZcG0Tl7hUucEl++CV58ncaeXpZBg0rTt4= 28 | github.com/oftn-oswg/socket v0.0.0-20180521040844-778302196f1e/go.mod h1:xWGgdiESbRta1W/AYVqI596DOZcyWUUyBOqbp16Vg60= 29 | github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= 30 | github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= 31 | github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= 32 | github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 36 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 37 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 38 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 39 | github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= 40 | github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= 41 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 42 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 44 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 45 | gopkg.in/ezzarghili/recaptcha-go.v2 v2.0.2 h1:TeeLEDzQDa5DjMWPkvutxlUvcP4pbSCuKFjY+cIZQXE= 46 | gopkg.in/ezzarghili/recaptcha-go.v2 v2.0.2/go.mod h1:74kD0fVwtCnu/JdVAPrMvEhcD+6ZMamoaFNs1OOfVc0= 47 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 48 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/jinzhu/configor" 12 | ) 13 | 14 | func main() { 15 | var config ZerodropConfig 16 | var configFile string 17 | 18 | // Parse configuration file from command line 19 | flag.StringVar(&configFile, "config", "/etc/zerodrop/config.yml", 20 | "Location of the configuration file") 21 | flag.Parse() 22 | 23 | // Population configuration struct 24 | configor.Load(&config, configFile) 25 | log.Printf("Loaded configuration: %#v", config) 26 | 27 | app, err := NewZerodropApp(&config) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | if err := app.Start(); err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | signalChan := make(chan os.Signal) 36 | signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) 37 | 38 | sig := <-signalChan 39 | fmt.Printf("Received signal %s\n", sig) 40 | app.Stop() 41 | } 42 | -------------------------------------------------------------------------------- /notfound.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // NotFoundHandler serves the "404" page 8 | type NotFoundHandler struct{} 9 | 10 | func (n NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 11 | // Serve as HTTP 200 to undo caching. 12 | http.Error(w, "File not found", 200) 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zerodrop", 3 | "version": "0.1", 4 | "description": "A stealth URL toolkit optimized for dropping malware and circumventing censorship filters", 5 | "main": "index.js", 6 | "repository": "git@github.com:oftn-oswg/zerodrop.git", 7 | "author": "Devin Samarin ", 8 | "license": "MIT", 9 | "private": true, 10 | "devDependencies": { 11 | "@types/jquery": "^3.3.2", 12 | "bootstrap": "^4.1.1", 13 | "jquery": "^3.3.1", 14 | "node-sass": "^4.9.0", 15 | "popper.js": "^1.14.3", 16 | "ts-loader": "^4.3.0", 17 | "typescript": "^2.8.3", 18 | "uglifyjs-webpack-plugin": "^1.2.5", 19 | "webpack": "^4.8.3", 20 | "webpack-cli": "^2.1.3" 21 | }, 22 | "scripts": { 23 | "build": "webpack --mode development", 24 | "dist": "webpack --mode production", 25 | "css": "node-sass --output-style compressed ./scss/zerodrop.scss ./static/zerodrop.css" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scss/zerodrop.scss: -------------------------------------------------------------------------------- 1 | @import "node_modules/bootstrap/scss/bootstrap"; 2 | -------------------------------------------------------------------------------- /shot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/oftn-oswg/ipcat" 16 | 17 | "github.com/oschwald/geoip2-golang" 18 | ) 19 | 20 | var headerCacheControl = "no-cache, no-store, must-revalidate" 21 | 22 | var sudo = &AdminClaims{Admin: true} 23 | 24 | // ShotHandler serves the requested page and removes it from the database, or 25 | // returns 404 page if not available. 26 | type ShotHandler struct { 27 | App *ZerodropApp 28 | Context *BlacklistContext 29 | } 30 | 31 | // NewShotHandler constructs a new ShotHandler from the arguments. 32 | func NewShotHandler(app *ZerodropApp) *ShotHandler { 33 | config := app.Config 34 | 35 | ctx := &BlacklistContext{Databases: make(map[string]*ipcat.IntervalSet)} 36 | 37 | if config.GeoDB != "" { 38 | var err error 39 | ctx.GeoDB, err = geoip2.Open(config.GeoDB) 40 | if err != nil { 41 | log.Printf("Could not open geolocation database: %s", err.Error()) 42 | } 43 | } 44 | 45 | for key, location := range config.IPCat { 46 | key = strings.ToLower(key) 47 | 48 | reader, err := os.Open(location) 49 | if err != nil { 50 | log.Printf("Could not open database %q: %s", key, err.Error()) 51 | continue 52 | } 53 | 54 | ipset := ipcat.NewIntervalSet(4096) 55 | 56 | if err := ipset.ImportCSV(reader); err != nil { 57 | log.Printf("Could not import database %q: %s", key, err.Error()) 58 | continue 59 | } 60 | 61 | ctx.Databases[key] = ipset 62 | } 63 | 64 | return &ShotHandler{ 65 | App: app, 66 | Context: ctx, 67 | } 68 | } 69 | 70 | // Access returns the ZerodropEntry with the specified name as long as access 71 | // is permitted. The function returns nil otherwise. 72 | func (a *ShotHandler) Access(name string, request *http.Request, redirectLevels int, direct bool) *ZerodropEntry { 73 | if name == "" { 74 | return nil 75 | } 76 | 77 | if redirectLevels <= 0 { 78 | log.Println("Exceeded redirection levels") 79 | } 80 | redirectLevels-- 81 | 82 | if name == a.App.Config.SelfDestruct.Keyword { 83 | log.Println("Self destruct invoked") 84 | a.SelfDestruct() 85 | return nil 86 | } 87 | 88 | ip := RealRemoteIP(request) 89 | if ip == nil { 90 | log.Printf("Could not parse remote address from %s", request.RemoteAddr) 91 | return nil 92 | } 93 | 94 | entry, err := a.App.DB.Get(name) 95 | if err != nil { 96 | return nil 97 | } 98 | 99 | if entry.AccessTrain { 100 | date := time.Now().Format(time.RFC1123) 101 | entry.AccessBlacklist.Add(&BlacklistRule{Comment: "Automatically added by training on " + date}) 102 | 103 | // We need to add the ip to the blacklist 104 | entry.AccessBlacklist.Add(&BlacklistRule{IP: ip}) 105 | 106 | // We will also add the Geofence 107 | if a.Context.GeoDB != nil { 108 | record, err := a.Context.GeoDB.City(ip) 109 | if err == nil { 110 | entry.AccessBlacklist.Add(&BlacklistRule{ 111 | Geofence: &Geofence{ 112 | Latitude: record.Location.Latitude, 113 | Longitude: record.Location.Longitude, 114 | Radius: float64(record.Location.AccuracyRadius) * 1000.0, // Convert km to m 115 | }, 116 | }) 117 | } 118 | } 119 | 120 | if err := a.App.DB.Update(entry, sudo); err != nil { 121 | log.Printf("Error adding to blacklist: %s", err.Error()) 122 | } 123 | return a.Access(entry.AccessRedirectOnDeny, request, redirectLevels, false) 124 | } 125 | 126 | if entry.IsExpired() { 127 | entry.AccessBlacklistCount++ 128 | if err := a.App.DB.Update(entry, sudo); err != nil { 129 | log.Println(err) 130 | } 131 | return a.Access(entry.AccessRedirectOnDeny, request, redirectLevels, false) 132 | } 133 | 134 | if !entry.AccessBlacklist.Allow(a.Context, ip) { 135 | entry.AccessBlacklistCount++ 136 | if err := a.App.DB.Update(entry, sudo); err != nil { 137 | log.Println(err) 138 | } 139 | return a.Access(entry.AccessRedirectOnDeny, request, redirectLevels, false) 140 | } 141 | 142 | entry.AccessCount++ 143 | if err := a.App.DB.Update(entry, sudo); err != nil { 144 | log.Println(err) 145 | } 146 | 147 | return entry 148 | } 149 | 150 | // ServeHTTP generates the HTTP response. 151 | func (a *ShotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 152 | // Get entry 153 | name := strings.Trim(r.URL.Path, "/") 154 | if name == "" { 155 | a.App.AdminHandler.ServeHTTP(w, r) 156 | return 157 | } 158 | 159 | entry := a.Access(name, r, a.App.Config.RedirectLevels, true) 160 | 161 | ip := RealRemoteIP(r) 162 | 163 | if entry == nil { 164 | log.Printf("Denied access to %s to %s", strconv.Quote(name), ip) 165 | a.App.NotFound.ServeHTTP(w, r) 166 | return 167 | } 168 | 169 | log.Printf("Granted access to %s to %s", strconv.Quote(entry.Name), ip) 170 | 171 | // File Upload 172 | if entry.URL == "" { 173 | contentType := entry.ContentType 174 | if contentType == "" { 175 | contentType = "text/plain" 176 | } 177 | 178 | fullpath := filepath.Join(a.App.Config.UploadDirectory, entry.Filename) 179 | file, err := os.Open(fullpath) 180 | if err != nil { 181 | http.Error(w, err.Error(), 500) 182 | return 183 | } 184 | defer file.Close() 185 | 186 | w.Header().Set("Content-Type", contentType) 187 | w.Header().Set("Cache-Control", headerCacheControl) 188 | io.Copy(w, file) 189 | return 190 | } 191 | 192 | // URL redirect 193 | if entry.Redirect { 194 | // Perform a redirect to the URL. 195 | w.Header().Set("Cache-Control", headerCacheControl) 196 | http.Redirect(w, r, entry.URL, 307) 197 | return 198 | } 199 | 200 | // URL proxy 201 | target, err := url.Parse(entry.URL) 202 | if err != nil { 203 | http.Error(w, "Could not parse URL", 500) 204 | return 205 | } 206 | 207 | proxy := &httputil.ReverseProxy{ 208 | Director: func(req *http.Request) { 209 | req.URL = target 210 | req.Host = target.Host 211 | if _, ok := req.Header["User-Agent"]; !ok { 212 | // explicitly disable User-Agent so it's not set to default value 213 | req.Header.Set("User-Agent", "") 214 | } 215 | }, 216 | ModifyResponse: func(res *http.Response) error { 217 | w.Header().Set("Cache-Control", headerCacheControl) 218 | return nil 219 | }, 220 | } 221 | 222 | proxy.ServeHTTP(w, r) 223 | } 224 | 225 | func (a *ShotHandler) SelfDestruct() { 226 | a.App.SelfDestruct() 227 | } 228 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- 1 | # These files are generated with webpack 2 | /zerodrop.js 3 | /zerodrop.js.map 4 | /zerodrop.css 5 | -------------------------------------------------------------------------------- /static/root/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /systemd/zerodrop@.service: -------------------------------------------------------------------------------- 1 | # Zerodrop 2 | # https://github.com/oftn-oswg/zerodrop 3 | 4 | # This is a systemd template unit file for Zerodrop. 5 | # See INSTALL.md for installation instructions. 6 | 7 | [Unit] 8 | Description=Zerodrop 9 | After=syslog.target 10 | After=network.target 11 | 12 | [Service] 13 | Type=simple 14 | User=zerodrop 15 | Group=zerodrop 16 | WorkingDirectory=/home/zerodrop/src/github.com/oftn-oswg/zerodrop/ 17 | PermissionsStartOnly=true 18 | ExecStartPre=/bin/mkdir -p /var/run/zerodrop 19 | ExecStartPre=/bin/chown zerodrop:www-data /var/run/zerodrop 20 | ExecStart=/usr/bin/time /home/zerodrop/%i/zerodrop -config /home/zerodrop/%i/config.yml 21 | Restart=always 22 | Environment=USER=zerodrop HOME=/home/zerodrop 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | -------------------------------------------------------------------------------- /templates/entries.tmpl: -------------------------------------------------------------------------------- 1 | {{define "entries"}} 2 | {{template "header" $}} 3 | 16 | {{if $.Error}} 17 | 18 | {{end}} 19 |
20 | {{if $.Entries}} 21 |
    22 | {{range $.Entries}} 23 |
  • 24 |
    25 | 26 | 27 |
    28 |
    29 | {{.Name}} 30 |
    31 | {{.AccessCount}}{{if .AccessExpire}}/{{.AccessExpireCount}}{{end}} 32 | {{if gt .AccessBlacklistCount 0}}{{.AccessBlacklistCount}}{{end}} 33 | {{if or $.Claims.Admin (not $.All) }} 34 | 46 | {{end}} 47 |
    48 | {{if ne .URL "" }} 49 |

    50 | 51 | {{.URL}} 52 | ({{if .Redirect}}redirection{{else}}proxy{{end}}) 53 | 54 |

    55 | {{end}} 56 |
    57 |
    58 |
    {{.AccessBlacklist}}
    59 |
    60 |
  • 61 | {{end}} 62 |
63 | {{else}} 64 |
65 | There are no entries here, but you can create an entry. 66 |
67 | {{end}} 68 | 69 | {{if and $.Entries (or $.Claims.Admin (not $.All)) }} 70 | 77 | {{end}} 78 |
79 | {{- template "footer" $ -}} 80 | {{end}} 81 | -------------------------------------------------------------------------------- /templates/footer.tmpl: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 | 3 | 4 | 5 | 6 | {{end}} 7 | -------------------------------------------------------------------------------- /templates/header.tmpl: -------------------------------------------------------------------------------- 1 | {{define "header"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{$.Title}} 9 | 10 | {{if $.Config.Recaptcha.SiteKey}} 11 | 12 | {{end}} 13 | 14 | 15 | 16 |
17 |
18 | {{if $.Claims.Admin}} 19 | Logout 20 | {{else}} 21 | Login 22 | {{end}} 23 |

Zerodrop 24 | Private content server 25 |

26 |
27 | {{end}} 28 | -------------------------------------------------------------------------------- /templates/login.tmpl: -------------------------------------------------------------------------------- 1 | {{define "login"}} 2 | {{template "header" $}} 3 |
4 |
5 |
6 |
Zerodrop Login
7 |
8 | {{if $.Error}} 9 | 10 | {{end}} 11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 | {{template "footer" $}} 22 | {{end}} 23 | -------------------------------------------------------------------------------- /templates/new.tmpl: -------------------------------------------------------------------------------- 1 | {{define "new"}} 2 | {{- template "header" $ -}} 3 | 16 | {{if $.Error}} 17 | 18 | {{end}} 19 |
20 | 21 |
22 |
23 | 40 |
41 |
42 |
43 | 44 |
45 | 46 |
47 |
48 |
49 | 50 | 56 |
57 |
58 | 59 | 65 |
66 |
67 |
68 |
69 | 70 |
71 |
72 | Upload 73 |
74 |
75 | 76 | 77 |
78 |
79 |
80 | 81 |
82 | 83 |
84 |
85 |
86 |
87 | 88 |
89 | 90 |
91 |
92 | 93 |
94 | 95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | 106 |
107 |
108 |
109 | 110 |
111 | 112 |
113 | request(s). 114 |
115 |
116 |
117 | 118 |
119 | 120 |
121 |
122 |
123 | 124 |
125 | 126 | 127 | Entry to redirect to once the page expires or the user has been blacklisted. 128 | 129 |
130 |
131 |
132 |
133 |
134 |
135 | {{if $.Config.Recaptcha.SiteKey}} 136 |
137 | {{end}} 138 |
139 |
140 |
141 | {{$.Config.Base}} 142 |
143 | 144 |
145 | 146 |
147 |
148 |
149 | 150 |
151 |
152 |
153 |
154 |
155 | {{- template "footer" $ -}} 156 | {{end}} 157 | -------------------------------------------------------------------------------- /ts/emoji-favicon-toolkit.ts: -------------------------------------------------------------------------------- 1 | // https://w3c.github.io/ServiceWorker/#extendableevent-interface 2 | interface ExtendableEvent extends Event { 3 | waitUntil(f: Promise): void; 4 | } 5 | 6 | // https://w3c.github.io/ServiceWorker/#fetchevent-interface 7 | interface FetchEvent extends ExtendableEvent { 8 | readonly request: Request; 9 | readonly preloadResponse: Promise; 10 | readonly clientId: string; 11 | readonly reservedClientId: string; 12 | readonly targetClientId: string; 13 | 14 | respondWith(r: Promise): void; 15 | } 16 | 17 | const is_worker = !self.document; 18 | const mime_image = 'image/png'; 19 | 20 | // Window load promise 21 | const window_load = new Promise((resolve: (value?: any) => void): void => { 22 | window.addEventListener('load', resolve); 23 | }); 24 | 25 | // Constants 26 | const ns = 'http://www.w3.org/1999/xhtml'; 27 | const mime_text_regex = /^\s*(?:text\/plain)\s*(?:$|;)/i; 28 | const size = 256; // Anything larger will causes problems in Google Chrome 29 | const pixelgrid = 16; 30 | const self_uri = document.currentScript.getAttribute('src'); 31 | const service_worker_container = navigator.serviceWorker; 32 | 33 | // Elements 34 | const canvas = document.createElementNS(ns, 'canvas') as HTMLCanvasElement; 35 | const link = document.createElementNS(ns, 'link') as HTMLLinkElement; 36 | const context = canvas.getContext('2d'); 37 | 38 | // Function 39 | export default function set_emoji_favicon(emoji: any, cacheWithServiceWorker?: any): void { 40 | // Normalize arguments 41 | const char = String(emoji) || ''; 42 | const cache = Boolean(cacheWithServiceWorker); 43 | 44 | // Calculate sizing 45 | const metric = context.measureText(char); 46 | const iconsize = metric.width; 47 | const center = (size + size / pixelgrid) / 2; 48 | 49 | const scale = Math.min(size / iconsize, 1); 50 | const center_scaled = center / scale; 51 | 52 | // Draw emoji 53 | context.clearRect(0, 0, size, size); 54 | context.save(); 55 | context.scale(scale, scale); 56 | context.fillText(char, center_scaled, center_scaled); 57 | context.restore(); 58 | 59 | // Update favicon element 60 | link.href = canvas.toDataURL(mime_image); 61 | document.getElementsByTagName('head')[0].appendChild(link); 62 | 63 | // Add favicon to cache 64 | if (cache && service_worker_container) { 65 | canvas.toBlob((blob: Blob): void => { 66 | const reader = new FileReader(); 67 | reader.addEventListener('loadend', () => { 68 | const array_buffer = reader.result; 69 | // https://developers.google.com/web/fundamentals/primers/service-workers/registration 70 | window_load.then(() => { 71 | service_worker_container.register(self_uri, { scope: '/' }); 72 | service_worker_container.ready.then((registration: ServiceWorkerRegistration) => { 73 | // https://developers.google.com/web/updates/2011/12/Transferable-Objects-Lightning-Fast 74 | registration.active.postMessage(array_buffer, [array_buffer]); 75 | }) 76 | }); 77 | }); 78 | reader.readAsArrayBuffer(blob); 79 | }, mime_image); 80 | } 81 | } 82 | 83 | // Canvas setup 84 | canvas.width = canvas.height = size; 85 | context.font = `normal normal normal ${size}px/${size}px apple color emoji, segoe ui emoji, notocoloremoji, segoe ui symbol, android emoji, emojisymbols, emojione mozilla, sans-serif`; 86 | context.textAlign = 'center'; 87 | context.textBaseline = 'middle'; 88 | 89 | // Link setup 90 | link.rel = 'icon'; 91 | link.type = mime_image; 92 | link.setAttribute('sizes', `${size}x${size}`); 93 | 94 | // Scan document for statically-defined favicons 95 | const lastlink = [].slice.call(document.getElementsByTagNameNS(ns, 'link'), 0).filter((link: HTMLLinkElement) => { 96 | return link.rel.toLowerCase() === 'icon' && mime_text_regex.test(link.type); 97 | }).pop(); 98 | 99 | if (lastlink) { 100 | const xhr = new XMLHttpRequest; 101 | const uri = lastlink.href.trim().replace(/^data:(;base64)?,/, "data:text/plain;charset=utf-8$1,"); 102 | xhr.open('GET', uri); 103 | xhr.addEventListener('load', () => { 104 | if (xhr.readyState === xhr.DONE && xhr.status === 200) { 105 | const emoji = xhr.responseText; 106 | set_emoji_favicon(emoji, false); 107 | } 108 | }) 109 | xhr.send(); 110 | } 111 | -------------------------------------------------------------------------------- /ts/zerodrop.ts: -------------------------------------------------------------------------------- 1 | // Import page dependencies 2 | import * as $ from 'jquery'; 3 | import 'bootstrap/js/src/util'; 4 | import 'bootstrap/js/src/tab'; 5 | import 'bootstrap/js/src/collapse'; 6 | import 'bootstrap/js/src/dropdown'; 7 | 8 | import set_emoji_favicon from './emoji-favicon-toolkit'; 9 | 10 | function humanFileSize(bytes: number, si: boolean): string { 11 | const thresh = si ? 1000 : 1024; 12 | if (Math.abs(bytes) < thresh) { 13 | return bytes + ' B'; 14 | } 15 | const units = si 16 | ? ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 17 | : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; 18 | let u = -1; 19 | do { 20 | bytes /= thresh; 21 | ++u; 22 | } while (Math.abs(bytes) >= thresh && u < units.length - 1); 23 | return bytes.toFixed(1) + ' ' + units[u]; 24 | } 25 | 26 | $(() => { 27 | // Check for a "Generate UUID" button on the page. 28 | $('.zerodrop-uuid').click((event: JQuery.Event) => { 29 | const element = $(event.currentTarget); 30 | $(element.data('field')).val('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 31 | const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); 32 | return v.toString(16); 33 | })); 34 | }); 35 | 36 | // Custom file upload input 37 | $('.zerodrop-file').change((event: JQuery.Event) => { 38 | const element = $(event.currentTarget); 39 | const file = (element.prop('files'))[0]; 40 | $(element.data('name')).text(`${file.name} (${humanFileSize(file.size, true)})`); 41 | $(element.data('mime')).val(file.type); 42 | }); 43 | 44 | // New entry tabs 45 | $('.zerodrop-nav').click((event: JQuery.Event) => { 46 | const element = $(event.currentTarget); 47 | element.find('input').prop('checked', true); 48 | }); 49 | }); 50 | 51 | const emojis = ['\u26a0\ufe0f', '\uD83D\uDD17'] 52 | let index = 0; 53 | function update_emoji() { 54 | const emoji = emojis[index]; 55 | index = (index + 1) % emojis.length; 56 | set_emoji_favicon(emoji); 57 | } 58 | update_emoji(); 59 | setInterval(update_emoji, 1337) 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./static/", 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es5", 7 | "lib": [ 8 | "dom", 9 | "es2016" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/oftn-oswg/ipcat" 11 | ) 12 | 13 | // ParseSocketName produces a struct suitable for net.Dial 14 | // given a string representing a socket address to bind or connect to 15 | func ParseSocketName(value string) (string, string) { 16 | value = strings.TrimSpace(value) 17 | 18 | // If value begins with "unix:" then we are a Unix domain socket 19 | if strings.Index(value, "unix:") == 0 { 20 | return "unix", strings.TrimSpace(value[5:]) 21 | } 22 | 23 | // If value is a port number, prepend a colon 24 | if _, err := strconv.Atoi(value); err == nil { 25 | return "tcp", ":" + value 26 | } 27 | 28 | return "tcp", value 29 | } 30 | 31 | func getCloudflareSet() *ipcat.IntervalSet { 32 | ipset := ipcat.NewIntervalSet(24) 33 | cloudflareRanges, err := ipcat.DownloadCloudflare() 34 | if err != nil { 35 | log.Printf("Could not download Cloudflare ranges: %s", err) 36 | return nil 37 | } 38 | if err := ipcat.UpdateCloudflare(ipset, cloudflareRanges); err != nil { 39 | log.Printf("Could not update Cloudflare ranges: %s", err) 40 | return nil 41 | } 42 | log.Printf("Loaded %d Cloudflare records\n", ipset.Len()) 43 | return ipset 44 | } 45 | 46 | var cloudflareSet = getCloudflareSet() 47 | 48 | func peelRemoteIP(r *http.Request) net.IP { 49 | host, _, err := net.SplitHostPort(r.RemoteAddr) 50 | if err != nil { 51 | // We could not parse the host and port. 52 | // Assume this means local. 53 | return net.IPv6loopback 54 | } 55 | return net.ParseIP(host) 56 | } 57 | 58 | func peelLocalProxy(ip net.IP, r *http.Request) net.IP { 59 | if ip != nil { 60 | // Local proxies only! 61 | if ip.IsLoopback() { 62 | if real := r.Header.Get("X-Real-IP"); real != "" { 63 | headerip := net.ParseIP(real) 64 | if headerip != nil { 65 | return headerip 66 | } 67 | } 68 | } 69 | } 70 | return ip 71 | } 72 | 73 | func peelCloudflare(ip net.IP, r *http.Request) net.IP { 74 | if cloudflareSet != nil { 75 | // Cloudflare proxy only! 76 | record, err := cloudflareSet.Contains(ip.String()) 77 | if err == nil && record != nil { 78 | // We are being served by Cloudflare 79 | connectingIP := r.Header.Get("CF-Connecting-IP") 80 | if connectingIP != "" { 81 | return net.ParseIP(connectingIP) 82 | } 83 | } 84 | } 85 | return ip 86 | } 87 | 88 | // RealRemoteIP returns the value of the X-Real-IP header, 89 | // or the RemoteAddr property if the header does not exist. 90 | func RealRemoteIP(r *http.Request) net.IP { 91 | return peelCloudflare(peelLocalProxy(peelRemoteIP(r), r), r) 92 | } 93 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestRemoteAddr(t *testing.T) { 10 | tests := []struct { 11 | RemoteAddr string 12 | XRealIP string 13 | ConnectingIP string 14 | Result string 15 | }{{"162.158.246.25:83789", "", "8.8.8.8", "8.8.8.8"}} 16 | 17 | for _, test := range tests { 18 | header := make(http.Header) 19 | header.Add("CF-Connecting-IP", test.ConnectingIP) 20 | header.Add("X-Real-IP", test.XRealIP) 21 | 22 | actual := RealRemoteIP(&http.Request{ 23 | RemoteAddr: test.RemoteAddr, 24 | Header: header, 25 | }) 26 | 27 | expected := net.ParseIP(test.Result) 28 | if !expected.Equal(actual) { 29 | t.Errorf("For %#v got %s, expected %s", test, actual, expected) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const uglify = require('uglifyjs-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: path.join(__dirname, 'ts/zerodrop.ts'), 7 | output: { 8 | filename: 'zerodrop.js', 9 | path: path.join(__dirname, 'static') 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.tsx?$/, 15 | loader: 'ts-loader', 16 | exclude: /node_modules/ 17 | } 18 | ] 19 | }, 20 | resolve: { 21 | extensions: ['.tsx', '.ts', '.js'] 22 | }, 23 | devtool: 'source-map', 24 | plugins: [ 25 | new webpack.ProvidePlugin({ 26 | $: 'jquery', 27 | jQuery: 'jquery', 28 | Popper: 'popper.js' 29 | }), 30 | new uglify({ 31 | sourceMap: true 32 | }) 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /zerodrop.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "regexp" 10 | 11 | "github.com/oftn-oswg/socket" 12 | ) 13 | 14 | // ZerodropConfig holds the configuration for an application instance. 15 | type ZerodropConfig struct { 16 | Listen string `default:"8080"` 17 | Group string 18 | 19 | Base string `default:"/"` 20 | AuthSecret string `default:"ggVUtPQdIL3kuMSeHQgn7PW9nv3XuJBp"` 21 | AuthDigest string `default:"11a55ac5de2beb9146e01386dd978a13bb9b99388f5eb52e37f69a32e3d5f11e"` 22 | 23 | GeoDB string 24 | IPCat map[string]string 25 | 26 | UploadDirectory string `default:"."` 27 | UploadPermissions uint32 `default:"0600"` 28 | UploadMaxSize uint64 `default:"1000000"` 29 | 30 | Public bool `default:"false"` 31 | Disallow string `default:""` 32 | disallowRegexp *regexp.Regexp 33 | 34 | SelfDestruct struct { 35 | Enable bool `default:"false"` 36 | Keyword string `default:"\U0001f4a3"` // Bomb emoji 37 | Files []string 38 | } 39 | 40 | RedirectLevels int `default:"128"` 41 | 42 | Recaptcha struct { 43 | SiteKey string 44 | SecretKey string 45 | } 46 | 47 | DB struct { 48 | Driver string `default:"sqlite3"` 49 | Source string `default:"zerodrop.db"` 50 | } 51 | } 52 | 53 | type ZerodropApp struct { 54 | Config *ZerodropConfig 55 | Server *http.Server 56 | DB *ZerodropDB 57 | 58 | AdminHandler *AdminHandler 59 | ShotHandler *ShotHandler 60 | NotFound *NotFoundHandler 61 | } 62 | 63 | func NewZerodropApp(config *ZerodropConfig) (app *ZerodropApp, err error) { 64 | app = &ZerodropApp{ 65 | Config: config, 66 | Server: &http.Server{}, 67 | DB: &ZerodropDB{}, 68 | } 69 | 70 | if config.Disallow != "" { 71 | disallowRegexp, err := regexp.Compile(config.Disallow) 72 | if err != nil { 73 | return nil, fmt.Errorf("parsing Disallow field: %s", err) 74 | } 75 | config.disallowRegexp = disallowRegexp 76 | } 77 | 78 | app.AdminHandler, err = NewAdminHandler(app) 79 | if err != nil { 80 | return nil, err 81 | } 82 | app.ShotHandler = NewShotHandler(app) 83 | app.NotFound = &NotFoundHandler{} 84 | 85 | return app, nil 86 | } 87 | 88 | func (z *ZerodropApp) Start() error { 89 | config := z.Config 90 | db := z.DB 91 | 92 | network, address := socket.Parse(config.Listen) 93 | socket, err := socket.Listen(network, address, 0660) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | if err := db.Connect(config.DB.Driver, config.DB.Source); err != nil { 99 | return err 100 | } 101 | 102 | rootserver := http.FileServer(http.Dir("./static/root/")) 103 | 104 | mux := http.NewServeMux() 105 | mux.Handle("/", z.ShotHandler) 106 | mux.Handle("/admin/", z.AdminHandler) 107 | mux.Handle("/robots.txt", rootserver) 108 | mux.Handle("/favicon.ico", rootserver) 109 | 110 | z.Server.Handler = mux 111 | 112 | go z.Server.Serve(socket) 113 | 114 | return nil 115 | } 116 | 117 | func (z *ZerodropApp) Stop() { 118 | z.Server.Shutdown(context.Background()) 119 | } 120 | 121 | func (z *ZerodropApp) SelfDestruct() { 122 | if !z.Config.SelfDestruct.Enable { 123 | return 124 | } 125 | 126 | config := z.Config 127 | errors := []string{} 128 | tag := "SELF-DESTRUCT" 129 | 130 | log.Printf("%s: initiating!", tag) 131 | 132 | // Copy removals list 133 | removals := make([]string, len(config.SelfDestruct.Files)) 134 | copy(removals, config.SelfDestruct.Files) 135 | 136 | // Prepend uploads directory 137 | removals = append([]string{config.UploadDirectory}, removals...) 138 | 139 | // Prepend this binary 140 | exec, err := os.Executable() 141 | if err != nil { 142 | errors = append(errors, "zerodrop binary: "+os.Args[0]) 143 | log.Printf("%s: could not locate binary! %s", tag, err) 144 | } else { 145 | removals = append([]string{exec}, removals...) 146 | } 147 | 148 | for _, removal := range removals { 149 | err := os.RemoveAll(removal) 150 | if err != nil { 151 | errors = append(errors, removal) 152 | log.Printf("%s: %s", tag, err) 153 | } 154 | } 155 | 156 | if len(errors) > 0 { 157 | log.Printf("%s: Encountered errors with the following files; please remove manually.", tag) 158 | for _, err := range errors { 159 | log.Printf("%s: - %s", tag, err) 160 | } 161 | } 162 | 163 | log.Printf("%s: shutting down!", tag) 164 | os.Exit(3) 165 | } 166 | --------------------------------------------------------------------------------