From 4d79d9a627774132e9e4696107c75fde21807549 Mon Sep 17 00:00:00 2001 From: Slack Coder Date: Thu, 20 Dec 2018 15:05:38 +0100 Subject: initial commit --- README.md | 77 +++++++++ not-slackware.com | 468 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 README.md create mode 100755 not-slackware.com 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{^(?\S+):\s+(?\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]) -- cgit v1.2.3