aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSlack Coder <slackcoder@server.ky>2018-12-20 15:05:38 +0100
committerSlack Coder <slackcoder@server.ky>2018-12-22 10:59:43 +0100
commit4d79d9a627774132e9e4696107c75fde21807549 (patch)
treee4fe74853da6fe4c069c2a547c10bdb624e2c29f
downloadslackware.com-client-4d79d9a627774132e9e4696107c75fde21807549.tar.xz
initial commit
-rw-r--r--README.md77
-rwxr-xr-xnot-slackware.com468
2 files changed, 545 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f269612
--- /dev/null
+++ b/README.md
@@ -0,0 +1,77 @@
+not-slackware.com
+=================
+
+A minimalistic tool for interfacing with Slackware's mirrors and website from the command line.
+
+Locally, I rename this to slackware.com. Use this tool at your own risk.
+
+Note: Currently this project is pre-release and may be vulnerable to downgrade attacks.
+
+```
+ not-slackware.com [command]
+
+ changelog
+ - show changes described in the Changelog since the last update
+ download
+ - download the given packages
+ help
+ - list commands offered by this tool
+ list
+ - print all packages on the mirror
+ sync
+ - update knowledge about mirror's content
+ updates
+ - print all new and updated packages on mirror
+
+ Environment Variables
+
+ PACKAGE_CACHE ($ROOT/var/cache/packages)
+ - local directory for downloads
+ QUIET (false)
+ - turn off informational output: 'false' or 'true'
+ ROOT (/)
+ - target root directory
+ SLACKWARE_MIRROR (https://download.dlackware.com/slackware/slackware-current/)
+ - remote root slackware package location
+```
+
+Examples
+--------
+
+Download all updates:
+```
+not-slackware.com updates | xargs not-slackware.com download
+```
+
+Cron script to download updates and email changes to root:
+```
+#!/bin/sh
+set -e
+
+export QUIET=true
+export SLACKWARE_MIRROR=http://slack64y3pqluw32.onion/ftp.arm.slackware.com/slackwarearm/slackwarearm-current/
+export PATH=$PATH:/usr/local/sbin
+
+not-slackware.com sync
+TO_UPDATE="$(not-slackware.com updates)"
+if [ -n "$TO_UPDATE" ]; then
+ not-slackware.com updates | xargs not-slackware.com download
+ not-slackware.com changelog | mail -s "updates" root
+fi
+```
+
+Installation
+------------
+
+Download the not-slackware.com script, and copy it to /usr/local/sbin:
+
+```
+chmd +x not-slackware.com
+mv not-slackware.com /usr/local/sbin
+```
+
+Import Slackware's GPG key for the user running the tool:
+
+```
+gpg2 --keyserver pgp.mit.edu --recv-keys 0x6A4463C040102233
+```
diff --git a/not-slackware.com b/not-slackware.com
new file mode 100755
index 0000000..619c6ba
--- /dev/null
+++ b/not-slackware.com
@@ -0,0 +1,468 @@
+#!/usr/bin/env ruby
+
+require "time"
+
+# TODO: List mirrors
+# TODO: Read input from STDIN using '-'
+# TODO: Silence when backgrounded like 'scp'
+# TODO: List changelog entries per package
+# TODO: List removed packages mentioned in changelog
+
+env_vars = {}
+ROOT = ENV["ROOT"] || "/"
+ env_vars["ROOT"] = ["target root directory", "/"]
+CACHE = ENV["PACKAGE_CACHE"] || File.join(ROOT, "var/cache/packages")
+ env_vars["PACKAGE_CACHE"] = ["local directory for downloads", "$ROOT/var/cache/packages"]
+QUIET = ENV["QUIET"] || "false"
+ env_vars["QUIET"] = ["turn off informational output: 'false' or 'true'", "false"]
+MIRROR = ENV["SLACKWARE_MIRROR"] || "https://download.dlackware.com/slackware/slackware-current/"
+ env_vars["SLACKWARE_MIRROR"] = ["remote root slackware package location", "https://download.dlackware.com/slackware/slackware-current/"]
+
+CHANGELOG = "ChangeLog.txt"
+CHECKSUMS ="CHECKSUMS.md5"
+PACKAGES = File.join(ROOT, "/var/log/packages")
+
+#
+# Errors
+#
+
+class DownloadError < StandardError
+ def to_s
+ "could not download '#{super.to_s}'"
+ end
+end
+
+class VerificationError < StandardError
+ def initialize(file_name, info)
+ @file_name = file_name
+ @info = info
+ end
+
+ def to_s
+ msg = "verification failed for #{@file_name}"
+ msg += ": #{@info}" if @info
+ return msg
+ end
+end
+
+class NotFoundError < StandardError
+ def to_s
+ "no '#{super.to_s}' package found"
+ end
+end
+
+class SyncRequiredError < StandardError
+ def to_s
+ exe_name = File.basename($0)
+ "Try running '#{exe_name} sync': #{super.to_s}"
+ end
+end
+
+
+def exitOnError(arg)
+ if arg.is_a? StandardError
+ exitOnError([arg])
+ end
+ return unless arg.is_a? Array
+
+ errs = arg.select{|v| v.is_a? StandardError}
+ return if errs.empty?
+
+ errs.each{|err| STDERR.puts "#{err}"}
+ exit(1)
+end
+
+def verifySynced(path)
+ unless File.exists?(path)
+ exitOnError(SyncRequiredError.new("#{path} not found"))
+ end
+end
+
+#
+# Domain
+#
+
+class Package
+ def self.from_path(package_path)
+ name = File.basename(package_path)
+ ext = File.extname(name)
+ pkg_name = name.slice(0, name.length - ext.length)
+ return pkg_name
+ end
+
+ def self.name(package)
+ # TODO: Check if fail
+ reg_package=%r{^(.*?)-([^-]+)-([^-]+)-([^-]+)$}
+ name = reg_package.match(package)
+ return name[1]
+ end
+
+ def self.merge(arg1, arg2)
+ # this is not needed
+ lookup = {}
+ arg1.each{|p| lookup[Package.name(p)] = p}
+
+ filtered = arg2.select{|p|
+ name = Package.name(p)
+ ! lookup[name]
+ }
+ merged = arg1.concat(filtered)
+ return merged
+ end
+
+ def self.patched_packages(package_paths)
+ patch_paths = package_paths.select{|p| p.include?("/patches/")}
+ patches = patch_paths.map{|p| Package.from_path(p)}
+ all = package_paths.map{|p| Package.from_path(p)}
+ return self.merge(patches, all)
+ end
+end
+
+class Selector
+ def self.is_match?(selector, pkg)
+ pkg == selector || Package.name(pkg) == selector
+ end
+end
+
+class PackagePath
+ def self.only_base_packages(package_paths)
+ package_paths.reject{|pkg|
+ pkg.include?("extra/") ||
+ pkg.include?("pasture/") ||
+ pkg.include?("testing/")
+ }
+ end
+
+ # TODO: Check if package not found
+ # TODO: Correctly return multiple matches
+ def self.select(package_paths, selector)
+ if selector.include?("/") then
+ # file path is given, lets treat it strictly
+ found = package_paths.select{|p| p.include?(selector)}
+ return found.first if found.length > 0
+ end
+
+ patch_paths = package_paths.select{|p| p.include?("/patches/")}
+ found = patch_paths.select{|pkg_path|
+ Selector.is_match?(selector, Package.from_path(pkg_path))
+ }
+ return found.first if found.length > 0
+
+ other_paths = package_paths.select{|p| !p.include?("/patches/")}
+ found = other_paths.select{|pkg_path|
+ Selector.is_match?(selector, Package.from_path(pkg_path))
+ }
+ return found.first
+ end
+end
+
+class ChangeLog
+ BREAK="+--------------------------+\n"
+ PACKAGE_ADDED="Added"
+ PACKAGE_UPDATED="Updated"
+ PACKAGE_REBUILT="Rebuilt"
+ PACKAGE_REMOVED="Removed"
+ PACKAGE_REG=%r{^(?<package>\S+):\s+(?<action>\S+).}
+
+ def self.parse_entry(entry)
+ packages = entry.scan(PACKAGE_REG)
+ # PROBLEM: entry not properly described
+ # PROBLEM: package names do not have consistent naming
+ return {
+ content: entry,
+ packages: packages,
+ date: Time.parse(entry.lines.first),
+ }
+ end
+
+ def initialize(path)
+ File.open(path, "r") do |buf|
+ entries = buf.read().encode("UTF-8", invalid: :replace).split(BREAK)
+ @entries = entries.map{|e| ChangeLog.parse_entry(e)}
+ end
+ end
+
+ def new_packages_since(tim)
+ changes = @entries.select{|entry| entry[:date] > tim}
+ ret = changes.map{|entry|
+ ret = entry[:packages]
+ .select{|pkg| pkg[1] == PACKAGE_ADDED}
+ .map{|pkg| pkg[0]}
+ ret = PackagePath.only_base_packages(ret)
+ }.flatten
+ end
+
+ def updates_since(tim)
+ changes = @entries.select{|entry| entry[:date] > tim}
+ changes.map{|entry| entry[:content]}.join(BREAK)
+ end
+
+ def guess_latest_update(installed_packages)
+ is_installed = {}
+ installed_packages.each{|pkg| is_installed[pkg] = true}
+
+ last_install_at = @entries.find_index{|entry|
+ entry[:packages].any?{|entry_pkg|
+ pkg_name = Package.from_path(entry_pkg[0])
+ is_installed[pkg_name]
+ }
+ }
+ last_install_at = @entries.last if last_install_at == nil
+ @entries[last_install_at][:date]
+ end
+end
+
+class ChecksumFile
+ def self.package_paths(path)
+ err = verifySynced(path)
+ return err if err
+
+ # could fail
+ reg_package=%r{(\w+)\s+(\S+.txz)\n}
+ contents = File.read(path)
+ paths = contents.scan(reg_package).map{|m|
+ m[1]
+ }
+ return paths
+ end
+end
+
+def print_array(arr)
+ arr.each{|i|
+ puts i
+ }
+end
+
+class Local
+ def self.downloaded_packages()
+ ret = Dir.glob("#{CACHE}/*.txz").map{|p| Package.from_path(p)}
+ return ret
+ end
+
+ def self.installed_packages()
+ Dir.glob("#{PACKAGES}/*").map{|p| File.basename(p)}
+ end
+
+ def self.packages()
+ Package.merge(self.downloaded_packages(), self.installed_packages())
+ end
+
+ def self.gpg_verify(file_path)
+ ok = system("gpg2", "--verify-files", file_path, [:out, :err] => "/dev/null")
+ ok ? nil : VerificationError.new(file_path, "GPG check failed")
+ end
+
+ def self.md5sum_verify(checksum_path, file_path)
+ err = verifySynced(checksum_path)
+ return err if err
+
+ target_checksum = ""
+ ok = IO.popen(["md5sum", file_path]) {|out|
+ line = out.gets()
+ target_checksum = line.split()[0] if line
+ }
+ return VerificationError.new(file_path) unless ok
+
+ target_filename = ""
+ ok = IO.popen(["grep", target_checksum, checksum_path]) {|out|
+ line = out.gets()
+ target_filename = line.split()[1] if line
+ }
+ return VerificationError.new(file_path, "bad checksum #{target_checksum}") unless ok
+
+ if File.basename(target_filename) != File.basename(file_path)
+ return VerificationError.new(file_path, "bad checksum #{target_checksum}")
+ end
+ return nil
+ end
+
+ def self.download(mirror, dest, file_path)
+ url = File.join(mirror, file_path)
+ command = ["wget",
+ "--continue",
+ "--directory-prefix", dest,
+ url
+ ]
+ command << "--quiet" if QUIET == "true"
+
+ ok = system(*command)
+ ok ? nil : DownloadError.new(url)
+ end
+
+ def self.download_and_verify(mirror, remote_file_path)
+ file_name = File.basename(remote_file_path)
+
+ if File.extname(file_name) == ".txz" || file_name == CHECKSUMS
+ remote_file_path_sig = "#{remote_file_path}.asc"
+ local_file_path_sig = File.join(CACHE, "#{file_name}.asc")
+ err = download(mirror, CACHE, remote_file_path_sig)
+ File.unlink(local_file_path_sig) if err && File.exist?(local_file_path_sig)
+ return err if err
+ end
+
+ file_name = File.basename(remote_file_path)
+ err = self.download(mirror, CACHE, remote_file_path)
+ return err if err
+ local_file_path = File.join(CACHE, file_name)
+
+ if File.extname(file_name) == ".txz" || file_name == CHECKSUMS
+ err = self.gpg_verify(local_file_path_sig)
+ File.unlink(local_file_path) if err && File.exist?(local_file_path)
+ return err if err
+ end
+ unless file_name == CHECKSUMS
+ checksum_path = File.join(CACHE, CHECKSUMS)
+ err = self.md5sum_verify(checksum_path, local_file_path)
+ File.unlink(local_file_path) if err && File.exist?(local_file_path)
+ return err if err
+ end
+ end
+end
+
+#
+# Commands
+#
+
+def command_changelog(changelog_path, installed_packages)
+ exitOnError(verifySynced(changelog_path))
+
+ changelog = ChangeLog.new(changelog_path)
+ since = changelog.guess_latest_update(installed_packages)
+ updates = changelog.updates_since(since)
+ puts updates unless updates.empty?
+end
+
+def command_download(checksum_path, selectors)
+ package_paths = ChecksumFile.package_paths(checksum_path)
+
+ arg_paths = selectors.map{|selector|
+ ret = PackagePath.select(package_paths, selector)
+ ret || NotFoundError.new(selector)
+ }
+ exitOnError(arg_paths)
+
+ res = arg_paths.map{|path|
+ Local.download_and_verify(MIRROR, path)
+ }
+ exitOnError(res)
+end
+
+def command_list(checksum_path)
+ package_paths = ChecksumFile.package_paths(checksum_path)
+ remote_packages = Package.patched_packages(package_paths)
+ print_array(remote_packages.sort())
+end
+
+
+def command_sync(mirror)
+ errs = []
+ errs << Local.download_and_verify(mirror, CHECKSUMS)
+ errs << Local.download_and_verify(mirror, CHANGELOG)
+ exitOnError(errs)
+end
+
+def command_updates(changelog_path, checksum_path, installed_packages)
+ # TODO(MUST): protect against downgrade attacks
+
+ package_paths = ChecksumFile.package_paths(checksum_path)
+ package_paths = PackagePath.only_base_packages(package_paths)
+ remote_packages = Package.patched_packages(package_paths)
+ local_packages = installed_packages
+
+ lookup = {}
+ local_packages.each{|p|
+ name = Package.name(p)
+ lookup[name] = p
+ }
+
+ updates = remote_packages.select{|p|
+ name = Package.name(p)
+ lookup[name] && lookup[name] != p
+ }
+
+ changelog = ChangeLog.new(changelog_path)
+ since = changelog.guess_latest_update(installed_packages)
+ new_packages = changelog.new_packages_since(since)
+ updates = updates.concat(new_packages)
+
+ print_array(updates.sort())
+end
+
+#
+# Main
+#
+
+class Command
+ def initialize()
+ @commands = {}
+ @env_vars = {}
+ self.add_subcommand("help", "list commands offered by this tool", ->{self.help(STDOUT)})
+ end
+
+ def add_subcommand(name, description, command)
+ @commands[name] = { name:name, desc: description, cmd: command}
+ end
+
+ def add_env_var(name, description, default)
+ @env_vars[name] = { name:name, desc: description, default: default}
+ end
+
+ def run(name)
+ cmd = @commands[name]
+ cmd = cmd || {cmd: ->{self.help(STDERR)}}
+ cmd[:cmd].call()
+ end
+
+ def help(fd)
+ exe_name = File.basename($0)
+ fd.puts "#{exe_name} [command]"
+ fd.puts ""
+
+ @commands.to_a.sort.each{|h|
+ v = h[1]
+ fd.puts "\t#{v[:name]}"
+ fd.puts "\t\t- #{v[:desc]}"
+ }
+
+ fd.puts ""
+ fd.puts "Environment Variables"
+ fd.puts ""
+ @env_vars.to_a.sort.each{|h|
+ v = h[1]
+ fd.puts "\t#{v[:name]} (#{v[:default]})"
+ fd.puts "\t\t- #{v[:desc]}"
+ }
+
+ end
+end
+
+command = Command.new()
+
+env_vars.each{|h,v| command.add_env_var(h, v[0], v[1])}
+command.add_subcommand(
+ "changelog",
+ "show changes described in the Changelog since the last update",
+ ->{command_changelog(File.join(CACHE,CHANGELOG), Local.installed_packages())},
+)
+command.add_subcommand(
+ "download",
+ "download the given packages",
+ ->{to_download = ARGV.drop(1); command_download(File.join(CACHE, CHECKSUMS), to_download)},
+)
+# TODO: list w/ directory prefix and include pasture etc.
+command.add_subcommand(
+ "list",
+ "print all packages on the mirror",
+ ->{command_list(File.join(CACHE,CHECKSUMS))},
+)
+command.add_subcommand(
+ "sync",
+ "update knowledge about mirror's content",
+ ->{command_sync(MIRROR)},
+)
+command.add_subcommand(
+ "updates",
+ "print all new and updated packages on mirror",
+ ->{command_updates(File.join(CACHE,CHANGELOG), File.join(CACHE, CHECKSUMS), Local.packages())}
+)
+command.run(ARGV[0])